Compare commits
3 Commits
main
...
1f7f7da0c3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f7f7da0c3 | ||
|
|
152c2724fc | ||
|
|
cf7c415e22 |
7
.claude/settings.json
Normal file
7
.claude/settings.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(sort:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
Capture d’écran du 2026-01-15 19-07-42.png
Executable file
BIN
Capture d’écran du 2026-01-15 19-07-42.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 361 KiB |
BIN
Image collée (5).png
Executable file
BIN
Image collée (5).png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
15
analytics-ui/Dockerfile
Normal file
15
analytics-ui/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app.py .
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["python", "app.py"]
|
||||||
381
analytics-ui/app.py
Normal file
381
analytics-ui/app.py
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
import os
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
import redis
|
||||||
|
from flask import Flask, jsonify, render_template_string
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _env_int(name: str, default: int) -> int:
|
||||||
|
try:
|
||||||
|
return int(os.getenv(name, "") or default)
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def get_db_connection():
|
||||||
|
return psycopg2.connect(
|
||||||
|
host=os.getenv("PW_DB_HOST", "postgres"),
|
||||||
|
port=_env_int("PW_DB_PORT", 5432),
|
||||||
|
dbname=os.getenv("PW_DB_NAME", "pricewatch"),
|
||||||
|
user=os.getenv("PW_DB_USER", "pricewatch"),
|
||||||
|
password=os.getenv("PW_DB_PASSWORD", "pricewatch"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_db_metrics() -> Tuple[Dict[str, Any], Optional[str]]:
|
||||||
|
data: Dict[str, Any] = {"counts": {}, "latest_products": []}
|
||||||
|
try:
|
||||||
|
with get_db_connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT COUNT(*) FROM products")
|
||||||
|
data["counts"]["products"] = cur.fetchone()[0]
|
||||||
|
|
||||||
|
cur.execute("SELECT COUNT(*) FROM price_history")
|
||||||
|
data["counts"]["price_history"] = cur.fetchone()[0]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"SELECT COUNT(*) FROM scraping_logs"
|
||||||
|
)
|
||||||
|
data["counts"]["scraping_logs"] = cur.fetchone()[0]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, source, reference, title, last_updated_at
|
||||||
|
FROM products
|
||||||
|
ORDER BY last_updated_at DESC
|
||||||
|
LIMIT 5
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
data["latest_products"] = [
|
||||||
|
{
|
||||||
|
"id": row[0],
|
||||||
|
"source": row[1],
|
||||||
|
"reference": row[2],
|
||||||
|
"title": row[3] or "Sans titre",
|
||||||
|
"updated": row[4].strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
if row[4]
|
||||||
|
else "n/a",
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
return data, None
|
||||||
|
except Exception as exc: # pragma: no cover (simple explorer)
|
||||||
|
return data, str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_decimal(value):
|
||||||
|
if isinstance(value, Decimal):
|
||||||
|
return float(value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_products_list(limit: int = 200) -> Tuple[List[Dict[str, Any]], Optional[str]]:
|
||||||
|
rows: List[Dict[str, Any]] = []
|
||||||
|
try:
|
||||||
|
with get_db_connection() as conn:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
p.id,
|
||||||
|
p.source,
|
||||||
|
p.reference,
|
||||||
|
p.title,
|
||||||
|
p.url,
|
||||||
|
p.category,
|
||||||
|
p.description,
|
||||||
|
p.currency,
|
||||||
|
p.msrp,
|
||||||
|
p.last_updated_at,
|
||||||
|
ph.price,
|
||||||
|
ph.stock_status,
|
||||||
|
ph.fetch_status,
|
||||||
|
ph.fetch_method,
|
||||||
|
ph.fetched_at
|
||||||
|
FROM products p
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT price, stock_status, fetch_status, fetch_method, fetched_at
|
||||||
|
FROM price_history
|
||||||
|
WHERE product_id = p.id
|
||||||
|
ORDER BY fetched_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
) ph ON true
|
||||||
|
ORDER BY p.last_updated_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("last_updated_at"):
|
||||||
|
serialized["last_updated_at"] = serialized["last_updated_at"].strftime(
|
||||||
|
"%Y-%m-%d %H:%M:%S"
|
||||||
|
)
|
||||||
|
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 get_redis_client() -> redis.Redis:
|
||||||
|
return redis.Redis(
|
||||||
|
host=os.getenv("PW_REDIS_HOST", "redis"),
|
||||||
|
port=_env_int("PW_REDIS_PORT", 6379),
|
||||||
|
db=_env_int("PW_REDIS_DB", 0),
|
||||||
|
socket_connect_timeout=2,
|
||||||
|
socket_timeout=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_redis() -> Tuple[str, Optional[str]]:
|
||||||
|
client = get_redis_client()
|
||||||
|
try:
|
||||||
|
client.ping()
|
||||||
|
return "OK", None
|
||||||
|
except Exception as exc:
|
||||||
|
return "KO", str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
TEMPLATE = """
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>PriceWatch Analytics UI</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: "JetBrains Mono", system-ui, monospace; background:#1f1f1b; color:#ebe0c8; margin:0; padding:32px; }
|
||||||
|
main { max-width: 960px; margin: 0 auto; }
|
||||||
|
h1 { margin-bottom: 0; }
|
||||||
|
section { margin-top: 24px; background:#282828; border:1px solid rgba(255,255,255,0.08); padding:16px; border-radius:14px; box-shadow:0 14px 30px rgba(0,0,0,0.35); }
|
||||||
|
table { width:100%; border-collapse:collapse; margin-top:12px; }
|
||||||
|
th, td { text-align:left; padding:6px 8px; border-bottom:1px solid rgba(255,255,255,0.08); }
|
||||||
|
.status { display:inline-flex; align-items:center; gap:6px; font-size:14px; padding:4px 10px; border-radius:999px; background:rgba(255,255,255,0.05); }
|
||||||
|
.status.ok { background:rgba(184,187,38,0.15); }
|
||||||
|
.status.ko { background:rgba(251,73,52,0.2); }
|
||||||
|
.muted { color:rgba(255,255,255,0.5); font-size:13px; }
|
||||||
|
.browser-panel { margin-top: 16px; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.browser-controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||||
|
.browser-controls button { border-radius: 8px; border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04); color: inherit; padding: 6px 12px; cursor: pointer; transition: transform 0.15s ease; }
|
||||||
|
.browser-controls button:hover { transform: translateY(-1px); }
|
||||||
|
.browser-display { padding: 12px; border-radius: 12px; background: rgba(255,255,255,0.02); border: 1px solid rgba(255,255,255,0.08); min-height: 150px; font-size: 0.85rem; }
|
||||||
|
.browser-display dt { font-weight: 700; }
|
||||||
|
.browser-display dd { margin: 0 0 8px 0; }
|
||||||
|
.browser-indicator { font-size: 0.9rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<header>
|
||||||
|
<h1>PriceWatch Analytics UI</h1>
|
||||||
|
<p class="muted">PostgreSQL : {{ db_status }} · Redis : {{ redis_status }}</p>
|
||||||
|
</header>
|
||||||
|
<section>
|
||||||
|
<h2>Vue rapide</h2>
|
||||||
|
<div class="status {{ 'ok' if db_error is none else 'ko' }}">
|
||||||
|
Base : {{ db_status }}
|
||||||
|
</div>
|
||||||
|
<div class="status {{ 'ok' if redis_status == 'OK' else 'ko' }}">
|
||||||
|
Redis : {{ redis_status }}
|
||||||
|
</div>
|
||||||
|
{% if db_error or redis_error %}
|
||||||
|
<p class="muted">Erreurs : {{ db_error or '' }} {{ redis_error or '' }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Stats métier</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th>Produits</th><td>{{ metrics.counts.products }}</td></tr>
|
||||||
|
<tr><th>Historique prix</th><td>{{ metrics.counts.price_history }}</td></tr>
|
||||||
|
<tr><th>Logs de scraping</th><td>{{ metrics.counts.scraping_logs }}</td></tr>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Produits récemment mis à jour</h2>
|
||||||
|
{% if metrics.latest_products %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>ID</th><th>Store</th><th>Référence</th><th>Révision</th><th>Mis à jour</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in metrics.latest_products %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ item.id }}</td>
|
||||||
|
<td>{{ item.source }}</td>
|
||||||
|
<td>{{ item.reference }}</td>
|
||||||
|
<td>{{ item.title[:40] }}{% if item.title|length > 40 %}…{% endif %}</td>
|
||||||
|
<td>{{ item.updated }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">Aucun produit enregistré.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Parcourir la base (produits)</h2>
|
||||||
|
<div class="browser-panel">
|
||||||
|
<div class="browser-controls">
|
||||||
|
<button id="load-products">Charger les produits</button>
|
||||||
|
<button id="product-prev" disabled>Précédent</button>
|
||||||
|
<button id="product-next" disabled>Suivant</button>
|
||||||
|
<strong class="browser-indicator" id="product-indicator">0 / 0</strong>
|
||||||
|
<span class="muted" id="product-message"></span>
|
||||||
|
</div>
|
||||||
|
<dl class="browser-display" id="product-details">
|
||||||
|
<dt data-field="title">Titre</dt>
|
||||||
|
<dd id="product-title">-</dd>
|
||||||
|
<dt data-field="store">Store</dt>
|
||||||
|
<dd data-field="store">-</dd>
|
||||||
|
<dt data-field="reference">Référence</dt>
|
||||||
|
<dd data-field="reference">-</dd>
|
||||||
|
<dt data-field="price">Dernier prix</dt>
|
||||||
|
<dd data-field="price">-</dd>
|
||||||
|
<dt data-field="currency">Devise</dt>
|
||||||
|
<dd data-field="currency">-</dd>
|
||||||
|
<dt data-field="msrp">Prix conseillé</dt>
|
||||||
|
<dd data-field="msrp">-</dd>
|
||||||
|
<dt data-field="stock_status">Stock</dt>
|
||||||
|
<dd data-field="stock_status">-</dd>
|
||||||
|
<dt data-field="category">Catégorie</dt>
|
||||||
|
<dd data-field="category">-</dd>
|
||||||
|
<dt data-field="description">Description</dt>
|
||||||
|
<dd data-field="description">-</dd>
|
||||||
|
<dt data-field="last_updated_at">Dernière mise à jour</dt>
|
||||||
|
<dd data-field="last_updated_at">-</dd>
|
||||||
|
<dt data-field="fetched_at">Historique dernier scrap</dt>
|
||||||
|
<dd data-field="fetched_at">-</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const loadBtn = document.getElementById("load-products");
|
||||||
|
const prevBtn = document.getElementById("product-prev");
|
||||||
|
const nextBtn = document.getElementById("product-next");
|
||||||
|
const indicator = document.getElementById("product-indicator");
|
||||||
|
const message = document.getElementById("product-message");
|
||||||
|
const titleEl = document.getElementById("product-title");
|
||||||
|
const fields = Array.from(document.querySelectorAll("[data-field]")).reduce((acc, el) => {
|
||||||
|
acc[el.getAttribute("data-field")] = el;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
let products = [];
|
||||||
|
let cursor = 0;
|
||||||
|
|
||||||
|
const setStatus = (text) => {
|
||||||
|
message.textContent = text || "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderProduct = () => {
|
||||||
|
if (!products.length) {
|
||||||
|
indicator.textContent = "0 / 0";
|
||||||
|
titleEl.textContent = "-";
|
||||||
|
Object.values(fields).forEach((el) => (el.textContent = "-"));
|
||||||
|
prevBtn.disabled = true;
|
||||||
|
nextBtn.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const current = products[cursor];
|
||||||
|
indicator.textContent = `${cursor + 1} / ${products.length}`;
|
||||||
|
titleEl.textContent = current.title || "Sans titre";
|
||||||
|
const mapField = {
|
||||||
|
store: current.source,
|
||||||
|
reference: current.reference,
|
||||||
|
price: current.price !== null && current.price !== undefined ? current.price : "n/a",
|
||||||
|
currency: current.currency || "EUR",
|
||||||
|
msrp: current.msrp || "-",
|
||||||
|
stock_status: current.stock_status || "n/a",
|
||||||
|
category: current.category || "n/a",
|
||||||
|
description: (current.description || "n/a").slice(0, 200),
|
||||||
|
last_updated_at: current.last_updated_at || "n/a",
|
||||||
|
fetched_at: current.fetched_at || "n/a",
|
||||||
|
};
|
||||||
|
Object.entries(mapField).forEach(([key, value]) => {
|
||||||
|
if (fields[key]) {
|
||||||
|
fields[key].textContent = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
prevBtn.disabled = cursor === 0;
|
||||||
|
nextBtn.disabled = cursor >= products.length - 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchProducts = async () => {
|
||||||
|
setStatus("Chargement…");
|
||||||
|
try {
|
||||||
|
const response = await fetch("/products.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");
|
||||||
|
}
|
||||||
|
products = data;
|
||||||
|
cursor = 0;
|
||||||
|
setStatus(`Chargé ${products.length} produit(s)`);
|
||||||
|
renderProduct();
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(`Erreur: ${err.message}`);
|
||||||
|
products = [];
|
||||||
|
renderProduct();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadBtn.addEventListener("click", fetchProducts);
|
||||||
|
prevBtn.addEventListener("click", () => {
|
||||||
|
if (cursor > 0) {
|
||||||
|
cursor -= 1;
|
||||||
|
renderProduct();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
nextBtn.addEventListener("click", () => {
|
||||||
|
if (cursor + 1 < products.length) {
|
||||||
|
cursor += 1;
|
||||||
|
renderProduct();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def root():
|
||||||
|
metrics, db_error = fetch_db_metrics()
|
||||||
|
redis_status, redis_error = check_redis()
|
||||||
|
return render_template_string(
|
||||||
|
TEMPLATE,
|
||||||
|
metrics=metrics,
|
||||||
|
db_status="connecté" if db_error is None else "erreur",
|
||||||
|
db_error=db_error,
|
||||||
|
redis_status=redis_status,
|
||||||
|
redis_error=redis_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/products.json")
|
||||||
|
def products_json():
|
||||||
|
products, error = fetch_products_list()
|
||||||
|
if error:
|
||||||
|
return jsonify({"error": error}), 500
|
||||||
|
return jsonify(products)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", port=80)
|
||||||
3
analytics-ui/requirements.txt
Normal file
3
analytics-ui/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
Flask==3.0.0
|
||||||
|
psycopg2-binary==2.9.11
|
||||||
|
redis==5.0.0
|
||||||
@@ -40,9 +40,41 @@ services:
|
|||||||
- "3000:80"
|
- "3000:80"
|
||||||
environment:
|
environment:
|
||||||
TZ: Europe/Paris
|
TZ: Europe/Paris
|
||||||
|
VITE_API_TOKEN: ${API_TOKEN:-}
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
depends_on:
|
depends_on:
|
||||||
- api
|
- api
|
||||||
|
|
||||||
|
analytics-ui:
|
||||||
|
build: ./analytics-ui
|
||||||
|
ports:
|
||||||
|
- "8070:80"
|
||||||
|
environment:
|
||||||
|
TZ: Europe/Paris
|
||||||
|
PW_DB_HOST: postgres
|
||||||
|
PW_DB_PORT: 5432
|
||||||
|
PW_DB_NAME: pricewatch
|
||||||
|
PW_DB_USER: pricewatch
|
||||||
|
PW_DB_PASSWORD: pricewatch
|
||||||
|
PW_REDIS_HOST: redis
|
||||||
|
PW_REDIS_PORT: 6379
|
||||||
|
PW_REDIS_DB: 0
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
- redis
|
||||||
|
|
||||||
|
adminer:
|
||||||
|
image: adminer
|
||||||
|
ports:
|
||||||
|
- "8071:8080"
|
||||||
|
environment:
|
||||||
|
TZ: Europe/Paris
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pricewatch_pgdata:
|
pricewatch_pgdata:
|
||||||
pricewatch_redisdata:
|
pricewatch_redisdata:
|
||||||
|
|||||||
50
docs/issue-42-ui-readability.md
Normal file
50
docs/issue-42-ui-readability.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
## Objectif
|
||||||
|
Améliorer la clarté et la lisibilité de l’interface (catalogue, filtres, détails produit) **sans modifier la palette de couleurs existante**.
|
||||||
|
|
||||||
|
## Contraintes strictes
|
||||||
|
- Interdit : changement de couleurs (fond, accent, badges, etc.)
|
||||||
|
- Autorisé : typographie, espacements, hiérarchie, mise en page, libellés, tooltips, états, comportements hover/focus, clamp.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tâches
|
||||||
|
|
||||||
|
### Cartes produit (catalogue)
|
||||||
|
- [ ] Titre : line-clamp 2 lignes + ellipse
|
||||||
|
- [ ] Tooltip titre complet (survol + clavier)
|
||||||
|
- [ ] Prix : taille 18–20px, bold (prix = focal n°1)
|
||||||
|
- [ ] Delta : format standard ▲/▼ + % (sinon afficher —)
|
||||||
|
- [ ] Statuts : remplacer `unknown/n/a` par `En stock / Rupture / Inconnu / Erreur scrape`
|
||||||
|
- [ ] Badges statuts homogènes (sans changer couleurs)
|
||||||
|
- [ ] Actions : 1 action primaire visible, secondaires au hover ou menu “...”
|
||||||
|
- [ ] Tooltips obligatoires sur toutes les icônes + aria-label
|
||||||
|
|
||||||
|
### Panneau Détails (colonne droite)
|
||||||
|
- [ ] Découper en sections : Résumé / Prix / Historique / Source / Actions
|
||||||
|
- [ ] Prix dominant visuellement + espacement vertical accru
|
||||||
|
- [ ] URL cliquable + bouton copier + ASIN visible
|
||||||
|
- [ ] Actions regroupées en bas
|
||||||
|
|
||||||
|
### Filtres (colonne gauche)
|
||||||
|
- [ ] Afficher compteur `X affichés / Y`
|
||||||
|
- [ ] Chips filtres actifs (cliquables pour retirer)
|
||||||
|
- [ ] Bouton Reset filtres toujours visible
|
||||||
|
- [ ] Labels cohérents + placeholders explicites
|
||||||
|
|
||||||
|
### Comparaison
|
||||||
|
- [ ] Message guidage : “Sélectionnez 2 à 4 produits…”
|
||||||
|
- [ ] Afficher compteur de sélection (`2 sélectionnés`, etc.)
|
||||||
|
|
||||||
|
### Accessibilité
|
||||||
|
- [ ] Focus clavier visible
|
||||||
|
- [ ] Navigation clavier : Tab sur cartes, Enter ouvre détails
|
||||||
|
- [ ] Icônes avec aria-label + tooltips accessibles
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critères d’acceptation
|
||||||
|
- Prix clairement dominant sur cartes et détails
|
||||||
|
- Titres non envahissants (2 lignes max)
|
||||||
|
- Statuts compréhensibles (plus de unknown/n/a)
|
||||||
|
- Filtres : X/Y + chips + reset
|
||||||
|
- Aucune couleur modifiée
|
||||||
26
fonctionnement.md
Normal file
26
fonctionnement.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
## Fonctionnement général de PriceWatch
|
||||||
|
|
||||||
|
Lorsqu’un utilisateur colle une URL dans la web UI et déclenche l’ajout/déclenchement d’un scrap, voici le cheminement principal entre le **frontend Vue** et le **backend FastAPI** :
|
||||||
|
|
||||||
|
1. **Entrée utilisateur / validation**
|
||||||
|
* Le popup "Ajouter un produit" envoie `POST /scrape/preview` avec l’URL + le mode (HTTP ou Playwright).
|
||||||
|
* Les boutons "Ajouter" et "Enregistrer" sont accessibles après que la preview ait renvoyé un `ProductSnapshot`, sinon une erreur est affichée dans le popup.
|
||||||
|
|
||||||
|
2. **Backend (API)**
|
||||||
|
* L’endpoint `/scrape/preview` reçoit l’URL, détermine le store (via `pricewatch/app/core/registry.py`) et utilise un parser adapté (`pricewatch/app/stores/<store>/`) pour extraire titre, prix, images, description, caractéristiques, stock, etc.
|
||||||
|
* Si la page nécessite un navigateur, la stratégie Playwright (avec `pricewatch/app/scraping/playwright.py`) est déclenchée, sinon le fetch HTTP simple (`pricewatch/app/scraping/http.py`) suffit.
|
||||||
|
* Le snapshot structuré `ProductSnapshot` contient les métadonnées, la liste d’images (jpg/webp) et les champs `msrp`, `discount`, `categories`, `specs`, etc.
|
||||||
|
* En cas de succès, la preview renvoie un JSON que le frontend affiche dans le popup. En cas d’erreur (404, 401, scraping bloqué), l’utilisateur voit directement le message retourné.
|
||||||
|
|
||||||
|
3. **Confirmation / persist**
|
||||||
|
* Quand l’utilisateur clique sur "Enregistrer", la web UI déclenche `POST /scrape/commit` avec l’objet snapshot.
|
||||||
|
* Le backend réinsère les données dans la base (`pricewatch/app/core/io.py`) et l’API `/products` ou `/enqueue` peut ensuite réafficher ou re-scraper ce produit.
|
||||||
|
|
||||||
|
4. **Cycle de rafraîchissement**
|
||||||
|
* Le frontend peut aussi appeler `/enqueue` pour forcer un nouveau scrap d’une URL existante (bouton refresh dans la carte ou le détail).
|
||||||
|
* Le backend place la requête dans Redis (via `pricewatch/app/core/queue.py`), un worker la consomme, met à jour la base, et le frontend récupère les nouvelles données via `GET /products`.
|
||||||
|
|
||||||
|
5. **Observabilité / logs**
|
||||||
|
* Les étapes critiques (preview, commit, enqueue) génèrent des logs (backend/uvicorn) disponibles dans la web UI via les boutons logs. Les erreurs sont mises en rouge et peuvent être copiées pour diagnostic.
|
||||||
|
|
||||||
|
Ce flux respecte les contraintes : la web UI déroule les interactions, le backend orchestre le scraping (HTTP vs Playwright), applique la logique store et diffuse le résultat via les endpoints REST existants.
|
||||||
Binary file not shown.
Binary file not shown.
@@ -21,31 +21,32 @@ from sqlalchemy import and_, desc, func
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from pricewatch.app.api.schemas import (
|
from pricewatch.app.api.schemas import (
|
||||||
|
BackendLogEntry,
|
||||||
EnqueueRequest,
|
EnqueueRequest,
|
||||||
EnqueueResponse,
|
EnqueueResponse,
|
||||||
HealthStatus,
|
HealthStatus,
|
||||||
PriceHistoryOut,
|
|
||||||
PriceHistoryCreate,
|
PriceHistoryCreate,
|
||||||
|
PriceHistoryOut,
|
||||||
PriceHistoryUpdate,
|
PriceHistoryUpdate,
|
||||||
ProductOut,
|
|
||||||
ProductCreate,
|
ProductCreate,
|
||||||
|
ProductHistoryPoint,
|
||||||
|
ProductOut,
|
||||||
ProductUpdate,
|
ProductUpdate,
|
||||||
ScheduleRequest,
|
ScheduleRequest,
|
||||||
ScheduleResponse,
|
ScheduleResponse,
|
||||||
ScrapingLogOut,
|
|
||||||
ScrapingLogCreate,
|
|
||||||
ScrapingLogUpdate,
|
|
||||||
ScrapePreviewRequest,
|
|
||||||
ScrapePreviewResponse,
|
|
||||||
ScrapeCommitRequest,
|
ScrapeCommitRequest,
|
||||||
ScrapeCommitResponse,
|
ScrapeCommitResponse,
|
||||||
VersionResponse,
|
ScrapePreviewRequest,
|
||||||
BackendLogEntry,
|
ScrapePreviewResponse,
|
||||||
|
ScrapingLogCreate,
|
||||||
|
ScrapingLogOut,
|
||||||
|
ScrapingLogUpdate,
|
||||||
UvicornLogEntry,
|
UvicornLogEntry,
|
||||||
WebhookOut,
|
VersionResponse,
|
||||||
WebhookCreate,
|
WebhookCreate,
|
||||||
WebhookUpdate,
|
WebhookOut,
|
||||||
WebhookTestResponse,
|
WebhookTestResponse,
|
||||||
|
WebhookUpdate,
|
||||||
)
|
)
|
||||||
from pricewatch.app.core.config import get_config
|
from pricewatch.app.core.config import get_config
|
||||||
from pricewatch.app.core.logging import get_logger
|
from pricewatch.app.core.logging import get_logger
|
||||||
@@ -794,6 +795,9 @@ def _read_uvicorn_lines(limit: int = 200) -> list[str]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
PRODUCT_HISTORY_LIMIT = 12
|
||||||
|
|
||||||
|
|
||||||
def _product_to_out(session: Session, product: Product) -> ProductOut:
|
def _product_to_out(session: Session, product: Product) -> ProductOut:
|
||||||
"""Helper pour mapper Product + dernier prix."""
|
"""Helper pour mapper Product + dernier prix."""
|
||||||
latest = (
|
latest = (
|
||||||
@@ -810,6 +814,18 @@ def _product_to_out(session: Session, product: Product) -> ProductOut:
|
|||||||
discount_amount = float(product.msrp) - float(latest.price)
|
discount_amount = float(product.msrp) - float(latest.price)
|
||||||
if product.msrp > 0:
|
if product.msrp > 0:
|
||||||
discount_percent = (discount_amount / float(product.msrp)) * 100
|
discount_percent = (discount_amount / float(product.msrp)) * 100
|
||||||
|
history_rows = (
|
||||||
|
session.query(PriceHistory)
|
||||||
|
.filter(PriceHistory.product_id == product.id, PriceHistory.price != None)
|
||||||
|
.order_by(desc(PriceHistory.fetched_at))
|
||||||
|
.limit(PRODUCT_HISTORY_LIMIT)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
history_points = [
|
||||||
|
ProductHistoryPoint(price=float(row.price), fetched_at=row.fetched_at)
|
||||||
|
for row in reversed(history_rows)
|
||||||
|
if row.price is not None
|
||||||
|
]
|
||||||
return ProductOut(
|
return ProductOut(
|
||||||
id=product.id,
|
id=product.id,
|
||||||
source=product.source,
|
source=product.source,
|
||||||
@@ -832,6 +848,7 @@ def _product_to_out(session: Session, product: Product) -> ProductOut:
|
|||||||
specs=specs,
|
specs=specs,
|
||||||
discount_amount=discount_amount,
|
discount_amount=discount_amount,
|
||||||
discount_percent=discount_percent,
|
discount_percent=discount_percent,
|
||||||
|
history=history_points,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ class HealthStatus(BaseModel):
|
|||||||
redis: bool
|
redis: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ProductHistoryPoint(BaseModel):
|
||||||
|
price: float
|
||||||
|
fetched_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class ProductOut(BaseModel):
|
class ProductOut(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
source: str
|
source: str
|
||||||
@@ -33,6 +38,7 @@ class ProductOut(BaseModel):
|
|||||||
specs: dict[str, str] = {}
|
specs: dict[str, str] = {}
|
||||||
discount_amount: Optional[float] = None
|
discount_amount: Optional[float] = None
|
||||||
discount_percent: Optional[float] = None
|
discount_percent: Optional[float] = None
|
||||||
|
history: list[ProductHistoryPoint] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class ProductCreate(BaseModel):
|
class ProductCreate(BaseModel):
|
||||||
|
|||||||
Binary file not shown.
BIN
pricewatch/app/core/__pycache__/io.cpython-313.pyc
Executable file → Normal file
BIN
pricewatch/app/core/__pycache__/io.cpython-313.pyc
Executable file → Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
pricewatch/app/scraping/__pycache__/pw_fetch.cpython-313.pyc
Executable file → Normal file
BIN
pricewatch/app/scraping/__pycache__/pw_fetch.cpython-313.pyc
Executable file → Normal file
Binary file not shown.
@@ -45,6 +45,8 @@ def fetch_playwright(
|
|||||||
timeout_ms: int = 60000,
|
timeout_ms: int = 60000,
|
||||||
save_screenshot: bool = False,
|
save_screenshot: bool = False,
|
||||||
wait_for_selector: Optional[str] = None,
|
wait_for_selector: Optional[str] = None,
|
||||||
|
wait_for_network_idle: bool = False,
|
||||||
|
extra_wait_ms: int = 0,
|
||||||
) -> PlaywrightFetchResult:
|
) -> PlaywrightFetchResult:
|
||||||
"""
|
"""
|
||||||
Récupère une page avec Playwright.
|
Récupère une page avec Playwright.
|
||||||
@@ -55,6 +57,8 @@ def fetch_playwright(
|
|||||||
timeout_ms: Timeout en millisecondes
|
timeout_ms: Timeout en millisecondes
|
||||||
save_screenshot: Prendre un screenshot
|
save_screenshot: Prendre un screenshot
|
||||||
wait_for_selector: Attendre un sélecteur CSS avant de récupérer
|
wait_for_selector: Attendre un sélecteur CSS avant de récupérer
|
||||||
|
wait_for_network_idle: Attendre que le réseau soit inactif (pour SPA)
|
||||||
|
extra_wait_ms: Délai supplémentaire après chargement (pour JS lent)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
PlaywrightFetchResult avec HTML, screenshot (optionnel), ou erreur
|
PlaywrightFetchResult avec HTML, screenshot (optionnel), ou erreur
|
||||||
@@ -65,6 +69,8 @@ def fetch_playwright(
|
|||||||
- Headful disponible pour debug visuel
|
- Headful disponible pour debug visuel
|
||||||
- Screenshot optionnel pour diagnostiquer les échecs
|
- Screenshot optionnel pour diagnostiquer les échecs
|
||||||
- wait_for_selector permet d'attendre le chargement dynamique
|
- wait_for_selector permet d'attendre le chargement dynamique
|
||||||
|
- wait_for_network_idle utile pour les SPA qui chargent via AJAX
|
||||||
|
- extra_wait_ms pour les sites avec JS lent après DOM ready
|
||||||
"""
|
"""
|
||||||
if not url or not url.strip():
|
if not url or not url.strip():
|
||||||
logger.error("URL vide fournie")
|
logger.error("URL vide fournie")
|
||||||
@@ -101,7 +107,8 @@ def fetch_playwright(
|
|||||||
|
|
||||||
# Naviguer vers la page
|
# Naviguer vers la page
|
||||||
logger.debug(f"[Playwright] Navigation vers {url}")
|
logger.debug(f"[Playwright] Navigation vers {url}")
|
||||||
response = page.goto(url, wait_until="domcontentloaded")
|
wait_until = "networkidle" if wait_for_network_idle else "domcontentloaded"
|
||||||
|
response = page.goto(url, wait_until=wait_until)
|
||||||
|
|
||||||
if not response:
|
if not response:
|
||||||
raise Exception("Pas de réponse du serveur")
|
raise Exception("Pas de réponse du serveur")
|
||||||
@@ -116,6 +123,11 @@ def fetch_playwright(
|
|||||||
f"[Playwright] Timeout en attendant le sélecteur: {wait_for_selector}"
|
f"[Playwright] Timeout en attendant le sélecteur: {wait_for_selector}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Délai supplémentaire pour JS lent (SPA)
|
||||||
|
if extra_wait_ms > 0:
|
||||||
|
logger.debug(f"[Playwright] Attente supplémentaire: {extra_wait_ms}ms")
|
||||||
|
page.wait_for_timeout(extra_wait_ms)
|
||||||
|
|
||||||
# Récupérer le HTML
|
# Récupérer le HTML
|
||||||
html = page.content()
|
html = page.content()
|
||||||
|
|
||||||
|
|||||||
BIN
pricewatch/app/stores/__pycache__/base.cpython-313.pyc
Executable file → Normal file
BIN
pricewatch/app/stores/__pycache__/base.cpython-313.pyc
Executable file → Normal file
Binary file not shown.
Binary file not shown.
BIN
pricewatch/app/stores/aliexpress/__pycache__/store.cpython-313.pyc
Executable file → Normal file
BIN
pricewatch/app/stores/aliexpress/__pycache__/store.cpython-313.pyc
Executable file → Normal file
Binary file not shown.
@@ -29,13 +29,39 @@ logger = get_logger("stores.aliexpress")
|
|||||||
|
|
||||||
|
|
||||||
class AliexpressStore(BaseStore):
|
class AliexpressStore(BaseStore):
|
||||||
"""Store pour AliExpress.com (marketplace chinois)."""
|
"""Store pour AliExpress.com (marketplace chinois).
|
||||||
|
|
||||||
|
AliExpress est une SPA (Single Page Application) qui charge
|
||||||
|
le contenu via JavaScript/AJAX. Nécessite Playwright avec
|
||||||
|
attente du chargement dynamique.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Initialise le store AliExpress avec ses sélecteurs."""
|
"""Initialise le store AliExpress avec ses sélecteurs."""
|
||||||
selectors_path = Path(__file__).parent / "selectors.yml"
|
selectors_path = Path(__file__).parent / "selectors.yml"
|
||||||
super().__init__(store_id="aliexpress", selectors_path=selectors_path)
|
super().__init__(store_id="aliexpress", selectors_path=selectors_path)
|
||||||
|
|
||||||
|
def get_spa_config(self) -> dict:
|
||||||
|
"""
|
||||||
|
Configuration SPA pour AliExpress.
|
||||||
|
|
||||||
|
AliExpress charge les données produit (prix, titre) via AJAX.
|
||||||
|
Il faut attendre que le réseau soit inactif ET ajouter un délai
|
||||||
|
pour laisser le JS terminer le rendu.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Configuration Playwright pour SPA
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"wait_for_network_idle": True,
|
||||||
|
"wait_for_selector": "h1", # Titre du produit
|
||||||
|
"extra_wait_ms": 2000, # 2s pour le rendu JS
|
||||||
|
}
|
||||||
|
|
||||||
|
def requires_playwright(self) -> bool:
|
||||||
|
"""AliExpress nécessite Playwright pour le rendu SPA."""
|
||||||
|
return True
|
||||||
|
|
||||||
def match(self, url: str) -> float:
|
def match(self, url: str) -> float:
|
||||||
"""
|
"""
|
||||||
Détecte si l'URL est AliExpress.
|
Détecte si l'URL est AliExpress.
|
||||||
@@ -206,28 +232,71 @@ class AliexpressStore(BaseStore):
|
|||||||
Extrait le prix.
|
Extrait le prix.
|
||||||
|
|
||||||
AliExpress n'a PAS de sélecteur CSS stable pour le prix.
|
AliExpress n'a PAS de sélecteur CSS stable pour le prix.
|
||||||
On utilise regex sur le HTML brut.
|
Stratégie multi-niveaux:
|
||||||
|
1. Chercher dans les données JSON embarquées
|
||||||
|
2. Chercher dans les spans avec classes contenant "price"
|
||||||
|
3. Regex sur le HTML brut
|
||||||
|
4. Meta tags og:price
|
||||||
"""
|
"""
|
||||||
# Pattern 1: Prix avant € (ex: "136,69 €")
|
# Priorité 1: Extraire depuis JSON embarqué (skuActivityAmount, formattedActivityPrice)
|
||||||
match = re.search(r"([0-9][0-9\\s.,\\u00a0\\u202f\\u2009]*)\\s*€", html)
|
json_patterns = [
|
||||||
|
r'"skuActivityAmount"\s*:\s*\{\s*"value"\s*:\s*(\d+(?:\.\d+)?)', # {"value": 123.45}
|
||||||
|
r'"formattedActivityPrice"\s*:\s*"([0-9,.\s]+)\s*€"', # "123,45 €"
|
||||||
|
r'"formattedActivityPrice"\s*:\s*"€\s*([0-9,.\s]+)"', # "€ 123.45"
|
||||||
|
r'"minPrice"\s*:\s*"([0-9,.\s]+)"', # "minPrice": "123.45"
|
||||||
|
r'"price"\s*:\s*"([0-9,.\s]+)"', # "price": "123.45"
|
||||||
|
r'"activityAmount"\s*:\s*\{\s*"value"\s*:\s*(\d+(?:\.\d+)?)', # activityAmount.value
|
||||||
|
]
|
||||||
|
for pattern in json_patterns:
|
||||||
|
match = re.search(pattern, html)
|
||||||
|
if match:
|
||||||
|
price = parse_price_text(match.group(1))
|
||||||
|
if price is not None and price > 0:
|
||||||
|
debug.notes.append(f"Prix extrait depuis JSON: {price}")
|
||||||
|
return price
|
||||||
|
|
||||||
|
# Priorité 2: Chercher dans les spans/divs avec classes contenant "price"
|
||||||
|
price_selectors = [
|
||||||
|
'span[class*="price--current"]',
|
||||||
|
'span[class*="price--sale"]',
|
||||||
|
'div[class*="price--current"]',
|
||||||
|
'span[class*="product-price"]',
|
||||||
|
'span[class*="Price_Price"]',
|
||||||
|
'div[class*="es--wrap"]', # Structure AliExpress spécifique
|
||||||
|
]
|
||||||
|
for selector in price_selectors:
|
||||||
|
elements = soup.select(selector)
|
||||||
|
for elem in elements:
|
||||||
|
text = elem.get_text(strip=True)
|
||||||
|
# Chercher un prix dans le texte
|
||||||
|
price_match = re.search(r'(\d+[,.\s]*\d*)\s*€|€\s*(\d+[,.\s]*\d*)', text)
|
||||||
|
if price_match:
|
||||||
|
price_str = price_match.group(1) or price_match.group(2)
|
||||||
|
price = parse_price_text(price_str)
|
||||||
|
if price is not None and price > 0:
|
||||||
|
debug.notes.append(f"Prix extrait depuis sélecteur {selector}")
|
||||||
|
return price
|
||||||
|
|
||||||
|
# Priorité 3: Prix avant € (ex: "136,69€" ou "136,69 €")
|
||||||
|
match = re.search(r'(\d+[,.\s\u00a0\u202f\u2009]*\d*)\s*€', html)
|
||||||
if match:
|
if match:
|
||||||
price = parse_price_text(match.group(1))
|
price = parse_price_text(match.group(1))
|
||||||
if price is not None:
|
if price is not None and price > 0:
|
||||||
return price
|
return price
|
||||||
|
|
||||||
# Pattern 2: € avant prix (ex: "€ 136.69")
|
# Priorité 4: € avant prix (ex: "€136.69" ou "€ 136.69")
|
||||||
match = re.search(r"€\\s*([0-9][0-9\\s.,\\u00a0\\u202f\\u2009]*)", html)
|
match = re.search(r'€\s*(\d+[,.\s\u00a0\u202f\u2009]*\d*)', html)
|
||||||
if match:
|
if match:
|
||||||
price = parse_price_text(match.group(1))
|
price = parse_price_text(match.group(1))
|
||||||
if price is not None:
|
if price is not None and price > 0:
|
||||||
return price
|
return price
|
||||||
|
|
||||||
# Pattern 3: Chercher dans meta tags (moins fiable)
|
# Priorité 5: Chercher dans meta tags (moins fiable)
|
||||||
og_price = soup.find("meta", property="og:price:amount")
|
og_price = soup.find("meta", property="og:price:amount")
|
||||||
if og_price:
|
if og_price:
|
||||||
price_str = og_price.get("content", "")
|
price_str = og_price.get("content", "")
|
||||||
price = parse_price_text(price_str)
|
price = parse_price_text(price_str)
|
||||||
if price is not None:
|
if price is not None and price > 0:
|
||||||
return price
|
return price
|
||||||
|
|
||||||
debug.errors.append("Prix non trouvé")
|
debug.errors.append("Prix non trouvé")
|
||||||
@@ -235,7 +304,7 @@ class AliexpressStore(BaseStore):
|
|||||||
|
|
||||||
def _extract_msrp(self, html: str, debug: DebugInfo) -> Optional[float]:
|
def _extract_msrp(self, html: str, debug: DebugInfo) -> Optional[float]:
|
||||||
"""Extrait le prix conseille si present."""
|
"""Extrait le prix conseille si present."""
|
||||||
match = re.search(r"originalPrice\"\\s*:\\s*\"([0-9\\s.,]+)\"", html)
|
match = re.search(r'originalPrice"\s*:\s*"([0-9\s.,]+)"', html)
|
||||||
if match:
|
if match:
|
||||||
price = parse_price_text(match.group(1))
|
price = parse_price_text(match.group(1))
|
||||||
if price is not None:
|
if price is not None:
|
||||||
|
|||||||
Binary file not shown.
@@ -215,6 +215,19 @@ class AmazonStore(BaseStore):
|
|||||||
|
|
||||||
def _extract_price(self, soup: BeautifulSoup, debug: DebugInfo) -> Optional[float]:
|
def _extract_price(self, soup: BeautifulSoup, debug: DebugInfo) -> Optional[float]:
|
||||||
"""Extrait le prix."""
|
"""Extrait le prix."""
|
||||||
|
# Priorité 1: combiner les spans séparés a-price-whole et a-price-fraction
|
||||||
|
# C'est le format le plus courant sur Amazon pour les prix avec centimes séparés
|
||||||
|
whole = soup.select_one("span.a-price-whole")
|
||||||
|
fraction = soup.select_one("span.a-price-fraction")
|
||||||
|
if whole and fraction:
|
||||||
|
whole_text = whole.get_text(strip=True).rstrip(",.")
|
||||||
|
fraction_text = fraction.get_text(strip=True)
|
||||||
|
if whole_text and fraction_text:
|
||||||
|
price = parse_price_text(f"{whole_text}.{fraction_text}")
|
||||||
|
if price is not None:
|
||||||
|
return price
|
||||||
|
|
||||||
|
# Priorité 2: essayer les sélecteurs (incluant a-price-whole seul avec prix complet)
|
||||||
selectors = self.get_selector("price", [])
|
selectors = self.get_selector("price", [])
|
||||||
if isinstance(selectors, str):
|
if isinstance(selectors, str):
|
||||||
selectors = [selectors]
|
selectors = [selectors]
|
||||||
@@ -227,16 +240,6 @@ class AmazonStore(BaseStore):
|
|||||||
if price is not None:
|
if price is not None:
|
||||||
return price
|
return price
|
||||||
|
|
||||||
# Fallback: chercher les spans séparés a-price-whole et a-price-fraction
|
|
||||||
whole = soup.select_one("span.a-price-whole")
|
|
||||||
fraction = soup.select_one("span.a-price-fraction")
|
|
||||||
if whole and fraction:
|
|
||||||
whole_text = whole.get_text(strip=True)
|
|
||||||
fraction_text = fraction.get_text(strip=True)
|
|
||||||
price = parse_price_text(f"{whole_text}.{fraction_text}")
|
|
||||||
if price is not None:
|
|
||||||
return price
|
|
||||||
|
|
||||||
debug.errors.append("Prix non trouvé")
|
debug.errors.append("Prix non trouvé")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
BIN
pricewatch/app/stores/backmarket/__pycache__/store.cpython-313.pyc
Executable file → Normal file
BIN
pricewatch/app/stores/backmarket/__pycache__/store.cpython-313.pyc
Executable file → Normal file
Binary file not shown.
@@ -152,5 +152,32 @@ class BaseStore(ABC):
|
|||||||
"""
|
"""
|
||||||
return self.selectors.get(key, default)
|
return self.selectors.get(key, default)
|
||||||
|
|
||||||
|
def get_spa_config(self) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
Retourne la configuration SPA pour Playwright si ce store est une SPA.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict avec les options Playwright ou None si pas une SPA:
|
||||||
|
- wait_for_selector: Sélecteur CSS à attendre avant scraping
|
||||||
|
- wait_for_network_idle: Attendre que le réseau soit inactif
|
||||||
|
- extra_wait_ms: Délai supplémentaire après chargement
|
||||||
|
|
||||||
|
Par défaut retourne None (pas de config SPA spécifique).
|
||||||
|
Les stores SPA doivent surcharger cette méthode.
|
||||||
|
"""
|
||||||
|
return None
|
||||||
|
|
||||||
|
def requires_playwright(self) -> bool:
|
||||||
|
"""
|
||||||
|
Indique si ce store nécessite obligatoirement Playwright.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True si Playwright est requis, False sinon
|
||||||
|
|
||||||
|
Par défaut False. Les stores avec anti-bot agressif ou
|
||||||
|
rendu SPA obligatoire doivent surcharger cette méthode.
|
||||||
|
"""
|
||||||
|
return False
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"<{self.__class__.__name__} id={self.store_id}>"
|
return f"<{self.__class__.__name__} id={self.store_id}>"
|
||||||
|
|||||||
BIN
pricewatch/app/stores/cdiscount/__pycache__/store.cpython-313.pyc
Executable file → Normal file
BIN
pricewatch/app/stores/cdiscount/__pycache__/store.cpython-313.pyc
Executable file → Normal file
Binary file not shown.
@@ -112,7 +112,7 @@ class CdiscountStore(BaseStore):
|
|||||||
currency = self._extract_currency(soup, debug_info)
|
currency = self._extract_currency(soup, debug_info)
|
||||||
stock_status = self._extract_stock(soup, debug_info)
|
stock_status = self._extract_stock(soup, debug_info)
|
||||||
images = self._extract_images(soup, debug_info)
|
images = self._extract_images(soup, debug_info)
|
||||||
category = self._extract_category(soup, debug_info)
|
category = self._extract_category(soup, debug_info, url)
|
||||||
specs = self._extract_specs(soup, debug_info)
|
specs = self._extract_specs(soup, debug_info)
|
||||||
description = self._extract_description(soup, debug_info)
|
description = self._extract_description(soup, debug_info)
|
||||||
msrp = self._extract_msrp(soup, debug_info)
|
msrp = self._extract_msrp(soup, debug_info)
|
||||||
@@ -180,7 +180,7 @@ class CdiscountStore(BaseStore):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _extract_price(self, soup: BeautifulSoup, debug: DebugInfo) -> Optional[float]:
|
def _extract_price(self, soup: BeautifulSoup, debug: DebugInfo) -> Optional[float]:
|
||||||
"""Extrait le prix."""
|
"""Extrait le prix (DOM puis JSON-LD)."""
|
||||||
selectors = self.get_selector("price", [])
|
selectors = self.get_selector("price", [])
|
||||||
if isinstance(selectors, str):
|
if isinstance(selectors, str):
|
||||||
selectors = [selectors]
|
selectors = [selectors]
|
||||||
@@ -188,16 +188,33 @@ class CdiscountStore(BaseStore):
|
|||||||
for selector in selectors:
|
for selector in selectors:
|
||||||
elements = soup.select(selector)
|
elements = soup.select(selector)
|
||||||
for element in elements:
|
for element in elements:
|
||||||
# Attribut content (schema.org) ou texte
|
|
||||||
price_text = element.get("content") or element.get_text(strip=True)
|
price_text = element.get("content") or element.get_text(strip=True)
|
||||||
|
|
||||||
price = parse_price_text(price_text)
|
price = parse_price_text(price_text)
|
||||||
if price is not None:
|
if price is not None:
|
||||||
return price
|
return price
|
||||||
|
|
||||||
|
price = self._extract_price_from_json_ld(soup)
|
||||||
|
if price is not None:
|
||||||
|
return price
|
||||||
|
|
||||||
debug.errors.append("Prix non trouvé")
|
debug.errors.append("Prix non trouvé")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _extract_price_from_json_ld(self, soup: BeautifulSoup) -> Optional[float]:
|
||||||
|
"""Extrait le prix depuis les scripts JSON-LD."""
|
||||||
|
product_ld = self._find_product_ld(soup)
|
||||||
|
offers = product_ld.get("offers")
|
||||||
|
if isinstance(offers, list):
|
||||||
|
offers = offers[0] if offers else None
|
||||||
|
if isinstance(offers, dict):
|
||||||
|
price = offers.get("price")
|
||||||
|
if isinstance(price, str):
|
||||||
|
return parse_price_text(price)
|
||||||
|
if isinstance(price, (int, float)):
|
||||||
|
# convert to float but maintain decimals
|
||||||
|
return float(price)
|
||||||
|
return None
|
||||||
|
|
||||||
def _extract_msrp(self, soup: BeautifulSoup, debug: DebugInfo) -> Optional[float]:
|
def _extract_msrp(self, soup: BeautifulSoup, debug: DebugInfo) -> Optional[float]:
|
||||||
"""Extrait le prix conseille."""
|
"""Extrait le prix conseille."""
|
||||||
selectors = [
|
selectors = [
|
||||||
@@ -205,6 +222,8 @@ class CdiscountStore(BaseStore):
|
|||||||
".price__old",
|
".price__old",
|
||||||
".c-price__strike",
|
".c-price__strike",
|
||||||
".price-strike",
|
".price-strike",
|
||||||
|
"div[data-e2e='strikedPrice']",
|
||||||
|
"div.SecondaryPrice-price",
|
||||||
]
|
]
|
||||||
for selector in selectors:
|
for selector in selectors:
|
||||||
element = soup.select_one(selector)
|
element = soup.select_one(selector)
|
||||||
@@ -212,6 +231,19 @@ class CdiscountStore(BaseStore):
|
|||||||
price = parse_price_text(element.get_text(strip=True))
|
price = parse_price_text(element.get_text(strip=True))
|
||||||
if price is not None:
|
if price is not None:
|
||||||
return price
|
return price
|
||||||
|
# Fallback: JSON-LD (offers price + promotions)
|
||||||
|
product_ld = self._find_product_ld(soup)
|
||||||
|
offer = product_ld.get("offers")
|
||||||
|
if isinstance(offer, dict):
|
||||||
|
price = offer.get("price")
|
||||||
|
if isinstance(price, str):
|
||||||
|
candidate = parse_price_text(price)
|
||||||
|
elif isinstance(price, (int, float)):
|
||||||
|
candidate = float(price)
|
||||||
|
else:
|
||||||
|
candidate = None
|
||||||
|
if candidate is not None:
|
||||||
|
return candidate
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _extract_currency(self, soup: BeautifulSoup, debug: DebugInfo) -> Optional[str]:
|
def _extract_currency(self, soup: BeautifulSoup, debug: DebugInfo) -> Optional[str]:
|
||||||
@@ -288,7 +320,7 @@ class CdiscountStore(BaseStore):
|
|||||||
|
|
||||||
return list(dict.fromkeys(images)) # Préserver l’ordre
|
return list(dict.fromkeys(images)) # Préserver l’ordre
|
||||||
|
|
||||||
def _extract_category(self, soup: BeautifulSoup, debug: DebugInfo) -> Optional[str]:
|
def _extract_category(self, soup: BeautifulSoup, debug: DebugInfo, url: str) -> Optional[str]:
|
||||||
"""Extrait la catégorie depuis les breadcrumbs."""
|
"""Extrait la catégorie depuis les breadcrumbs."""
|
||||||
selectors = self.get_selector("category", [])
|
selectors = self.get_selector("category", [])
|
||||||
if isinstance(selectors, str):
|
if isinstance(selectors, str):
|
||||||
@@ -310,6 +342,54 @@ class CdiscountStore(BaseStore):
|
|||||||
if parts:
|
if parts:
|
||||||
return parts[-1]
|
return parts[-1]
|
||||||
|
|
||||||
|
if title := self._extract_category_from_breadcrumbs(soup):
|
||||||
|
return title
|
||||||
|
return self._extract_category_from_url(url)
|
||||||
|
|
||||||
|
def _extract_category_from_breadcrumbs(self, soup: BeautifulSoup) -> Optional[str]:
|
||||||
|
"""Cherche un breadcrumb via JSON-LD (BreadcrumbList) et retourne l'avant-dernier item."""
|
||||||
|
entries = self._extract_json_ld_entries(soup)
|
||||||
|
for entry in entries:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
if entry.get("@type") != "BreadcrumbList":
|
||||||
|
continue
|
||||||
|
items = entry.get("itemListElement", [])
|
||||||
|
if not isinstance(items, list):
|
||||||
|
continue
|
||||||
|
positions = [
|
||||||
|
element.get("position")
|
||||||
|
for element in items
|
||||||
|
if isinstance(element, dict) and isinstance(element.get("position"), int)
|
||||||
|
]
|
||||||
|
max_pos = max(positions) if positions else None
|
||||||
|
for element in reversed(items):
|
||||||
|
if not isinstance(element, dict):
|
||||||
|
continue
|
||||||
|
position = element.get("position")
|
||||||
|
if max_pos is not None and position == max_pos:
|
||||||
|
continue
|
||||||
|
item = element.get("item", {})
|
||||||
|
name = item.get("name")
|
||||||
|
if name and isinstance(name, str):
|
||||||
|
title = name.strip()
|
||||||
|
if title:
|
||||||
|
return title
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _extract_category_from_url(self, url: str) -> Optional[str]:
|
||||||
|
"""Déduit la catégorie via l'URL /informatique/.../f-..."""
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
parsed = urlparse(url)
|
||||||
|
segments = [seg for seg in parsed.path.split("/") if seg]
|
||||||
|
breadcrumb = []
|
||||||
|
for segment in segments:
|
||||||
|
if segment.startswith("f-") or segment.startswith("p-"):
|
||||||
|
break
|
||||||
|
breadcrumb.append(segment)
|
||||||
|
if breadcrumb:
|
||||||
|
return breadcrumb[-1].replace("-", " ").title()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _extract_json_ld_entries(self, soup: BeautifulSoup) -> list[dict]:
|
def _extract_json_ld_entries(self, soup: BeautifulSoup) -> list[dict]:
|
||||||
|
|||||||
@@ -17,6 +17,18 @@ def parse_price_text(text: str) -> Optional[float]:
|
|||||||
if not text:
|
if not text:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
euro_suffix = re.search(r"([0-9 .,]+)\s*€\s*(\d{2})\b", text)
|
||||||
|
if euro_suffix:
|
||||||
|
integer_part = euro_suffix.group(1)
|
||||||
|
decimal_part = euro_suffix.group(2)
|
||||||
|
integer_clean = re.sub(r"[^\d]", "", integer_part)
|
||||||
|
if integer_clean:
|
||||||
|
cleaned_decimal = f"{integer_clean}.{decimal_part}"
|
||||||
|
try:
|
||||||
|
return float(cleaned_decimal)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
# Fallback to original replacement if suffix logic fails
|
||||||
text = re.sub(r"(\d)\s*€\s*(\d)", r"\1,\2", text)
|
text = re.sub(r"(\d)\s*€\s*(\d)", r"\1,\2", text)
|
||||||
cleaned = text.replace("\u00a0", " ").replace("\u202f", " ").replace("\u2009", " ")
|
cleaned = text.replace("\u00a0", " ").replace("\u202f", " ").replace("\u2009", " ")
|
||||||
cleaned = "".join(ch for ch in cleaned if ch.isdigit() or ch in ".,")
|
cleaned = "".join(ch for ch in cleaned if ch.isdigit() or ch in ".,")
|
||||||
|
|||||||
1
scraped/amazon_B08N5WRWNW.html
Normal file
1
scraped/amazon_B08N5WRWNW.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<html><body>content</body></html>
|
||||||
121
scripts/missing_data_by_store.py
Normal file
121
scripts/missing_data_by_store.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import os
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
|
||||||
|
def _env_str(name: str, default: str) -> str:
|
||||||
|
return os.environ.get(name, default)
|
||||||
|
|
||||||
|
|
||||||
|
def _env_int(name: str, default: int) -> int:
|
||||||
|
try:
|
||||||
|
return int(os.environ.get(name, default))
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def get_connection():
|
||||||
|
return psycopg2.connect(
|
||||||
|
host=_env_str("PW_DB_HOST", "localhost"),
|
||||||
|
port=_env_int("PW_DB_PORT", 5432),
|
||||||
|
dbname=_env_str("PW_DB_NAME", "pricewatch"),
|
||||||
|
user=_env_str("PW_DB_USER", "pricewatch"),
|
||||||
|
password=_env_str("PW_DB_PASSWORD", "pricewatch"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def gather(limit: Optional[int] = None):
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
COALESCE(p.source, 'unknown') AS source,
|
||||||
|
p.id,
|
||||||
|
p.reference,
|
||||||
|
p.title,
|
||||||
|
p.description,
|
||||||
|
p.category,
|
||||||
|
p.msrp,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM product_images WHERE product_id = p.id LIMIT 1
|
||||||
|
) AS has_image,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM product_specs WHERE product_id = p.id LIMIT 1
|
||||||
|
) AS has_specs,
|
||||||
|
ph.price,
|
||||||
|
ph.stock_status
|
||||||
|
FROM products p
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT price, stock_status
|
||||||
|
FROM price_history
|
||||||
|
WHERE product_id = p.id
|
||||||
|
ORDER BY fetched_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
) ph ON TRUE
|
||||||
|
ORDER BY p.last_updated_at DESC
|
||||||
|
"""
|
||||||
|
if limit:
|
||||||
|
query += f" LIMIT {limit}"
|
||||||
|
|
||||||
|
with get_connection() as conn:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(query)
|
||||||
|
return cur.fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def summarize(rows):
|
||||||
|
stores: Dict[str, Dict[str, object]] = {}
|
||||||
|
fields = [
|
||||||
|
("price", "Prix absent"),
|
||||||
|
("stock_status", "Statut stock manquant"),
|
||||||
|
("description", "Description manquante"),
|
||||||
|
("category", "Catégorie manquante"),
|
||||||
|
("msrp", "Prix conseillé absent"),
|
||||||
|
("has_image", "Images absentes"),
|
||||||
|
("has_specs", "Caractéristiques absentes"),
|
||||||
|
]
|
||||||
|
for row in rows:
|
||||||
|
store = row["source"] or "unknown"
|
||||||
|
entry = stores.setdefault(
|
||||||
|
store,
|
||||||
|
{
|
||||||
|
"total": 0,
|
||||||
|
"details": {field: [] for field, _ in fields},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
entry["total"] += 1
|
||||||
|
for field, label in fields:
|
||||||
|
value = row.get(field)
|
||||||
|
if field in ("has_image", "has_specs"):
|
||||||
|
missing = not value
|
||||||
|
else:
|
||||||
|
missing = value in (None, "", [])
|
||||||
|
if missing:
|
||||||
|
entry["details"][field].append(
|
||||||
|
{
|
||||||
|
"id": row["id"],
|
||||||
|
"reference": row["reference"],
|
||||||
|
"title": row["title"] or "Sans titre",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return fields, stores
|
||||||
|
|
||||||
|
|
||||||
|
def pretty_print(fields, stores):
|
||||||
|
for store, data in stores.items():
|
||||||
|
print(f"\n=== Store: {store} ({data['total']} produits) ===")
|
||||||
|
for field, label in fields:
|
||||||
|
unit = len(data["details"][field])
|
||||||
|
print(f" {label}: {unit}")
|
||||||
|
for item in data["details"][field][:5]:
|
||||||
|
print(f" - [{item['id']}] {item['reference']} · {item['title']}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
rows = gather(limit=1000)
|
||||||
|
fields, stores = summarize(rows)
|
||||||
|
pretty_print(fields, stores)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
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.
53
tests/api/test_auth_simple.py
Normal file
53
tests/api/test_auth_simple.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""Tests simples pour l'authentification API."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from pricewatch.app.api.main import require_token
|
||||||
|
|
||||||
|
|
||||||
|
class FakeConfig:
|
||||||
|
api_token = "valid-token"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeConfigNoToken:
|
||||||
|
api_token = None
|
||||||
|
|
||||||
|
|
||||||
|
def test_require_token_valid(monkeypatch):
|
||||||
|
"""Token valide ne leve pas d'exception."""
|
||||||
|
monkeypatch.setattr("pricewatch.app.api.main.get_config", lambda: FakeConfig())
|
||||||
|
# Ne doit pas lever d'exception
|
||||||
|
require_token("Bearer valid-token")
|
||||||
|
|
||||||
|
|
||||||
|
def test_require_token_missing(monkeypatch):
|
||||||
|
"""Token manquant leve 401."""
|
||||||
|
monkeypatch.setattr("pricewatch.app.api.main.get_config", lambda: FakeConfig())
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
require_token(None)
|
||||||
|
assert exc_info.value.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_require_token_invalid_format(monkeypatch):
|
||||||
|
"""Token sans Bearer leve 401."""
|
||||||
|
monkeypatch.setattr("pricewatch.app.api.main.get_config", lambda: FakeConfig())
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
require_token("invalid-format")
|
||||||
|
assert exc_info.value.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_require_token_wrong_value(monkeypatch):
|
||||||
|
"""Mauvais token leve 403."""
|
||||||
|
monkeypatch.setattr("pricewatch.app.api.main.get_config", lambda: FakeConfig())
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
require_token("Bearer wrong-token")
|
||||||
|
assert exc_info.value.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_require_token_not_configured(monkeypatch):
|
||||||
|
"""Token non configure leve 500."""
|
||||||
|
monkeypatch.setattr("pricewatch.app.api.main.get_config", lambda: FakeConfigNoToken())
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
require_token("Bearer any-token")
|
||||||
|
assert exc_info.value.status_code == 500
|
||||||
26
tests/api/test_logs_endpoints.py
Normal file
26
tests/api/test_logs_endpoints.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""Tests pour les endpoints de logs API."""
|
||||||
|
|
||||||
|
from pricewatch.app.api.main import list_backend_logs, BACKEND_LOGS
|
||||||
|
from pricewatch.app.api.schemas import BackendLogEntry
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_backend_logs_empty():
|
||||||
|
"""Liste des logs backend vide."""
|
||||||
|
BACKEND_LOGS.clear()
|
||||||
|
result = list_backend_logs()
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_backend_logs_with_entries():
|
||||||
|
"""Liste des logs backend avec entrees."""
|
||||||
|
from datetime import datetime
|
||||||
|
BACKEND_LOGS.clear()
|
||||||
|
entry = BackendLogEntry(level="INFO", message="Test log", time=datetime(2026, 1, 17, 12, 0, 0))
|
||||||
|
BACKEND_LOGS.append(entry)
|
||||||
|
|
||||||
|
result = list_backend_logs()
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0].message == "Test log"
|
||||||
|
assert result[0].level == "INFO"
|
||||||
|
|
||||||
|
BACKEND_LOGS.clear()
|
||||||
267
tests/api/test_products_funcs.py
Normal file
267
tests/api/test_products_funcs.py
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
"""Tests fonctions API produits avec mocks."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||||
|
|
||||||
|
from pricewatch.app.api.main import (
|
||||||
|
create_product,
|
||||||
|
get_product,
|
||||||
|
update_product,
|
||||||
|
delete_product,
|
||||||
|
list_prices,
|
||||||
|
create_price,
|
||||||
|
update_price,
|
||||||
|
delete_price,
|
||||||
|
)
|
||||||
|
from pricewatch.app.api.schemas import ProductCreate, ProductUpdate, PriceHistoryCreate, PriceHistoryUpdate
|
||||||
|
|
||||||
|
|
||||||
|
class MockProduct:
|
||||||
|
"""Mock Product model."""
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
self.id = kwargs.get("id", 1)
|
||||||
|
self.source = kwargs.get("source", "amazon")
|
||||||
|
self.reference = kwargs.get("reference", "REF123")
|
||||||
|
self.url = kwargs.get("url", "https://example.com")
|
||||||
|
self.title = kwargs.get("title", "Test Product")
|
||||||
|
self.category = kwargs.get("category")
|
||||||
|
self.description = kwargs.get("description")
|
||||||
|
self.currency = kwargs.get("currency", "EUR")
|
||||||
|
self.msrp = kwargs.get("msrp")
|
||||||
|
self.first_seen_at = kwargs.get("first_seen_at", datetime.now())
|
||||||
|
self.last_updated_at = kwargs.get("last_updated_at", datetime.now())
|
||||||
|
|
||||||
|
|
||||||
|
class MockPrice:
|
||||||
|
"""Mock PriceHistory model."""
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
self.id = kwargs.get("id", 1)
|
||||||
|
self.product_id = kwargs.get("product_id", 1)
|
||||||
|
self.price = kwargs.get("price", 99.99)
|
||||||
|
self.shipping_cost = kwargs.get("shipping_cost")
|
||||||
|
self.stock_status = kwargs.get("stock_status", "in_stock")
|
||||||
|
self.fetch_method = kwargs.get("fetch_method", "http")
|
||||||
|
self.fetch_status = kwargs.get("fetch_status", "success")
|
||||||
|
self.fetched_at = kwargs.get("fetched_at", datetime.now())
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateProduct:
|
||||||
|
"""Tests create_product."""
|
||||||
|
|
||||||
|
def test_create_success(self):
|
||||||
|
"""Cree un produit avec succes."""
|
||||||
|
session = MagicMock()
|
||||||
|
session.add = MagicMock()
|
||||||
|
session.commit = MagicMock()
|
||||||
|
session.refresh = MagicMock()
|
||||||
|
|
||||||
|
payload = ProductCreate(
|
||||||
|
source="amazon",
|
||||||
|
reference="NEW123",
|
||||||
|
url="https://amazon.fr/dp/NEW123",
|
||||||
|
title="New Product",
|
||||||
|
currency="EUR",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("pricewatch.app.api.main.Product") as MockProductClass:
|
||||||
|
mock_product = MockProduct(reference="NEW123")
|
||||||
|
MockProductClass.return_value = mock_product
|
||||||
|
|
||||||
|
with patch("pricewatch.app.api.main._product_to_out") as mock_to_out:
|
||||||
|
mock_to_out.return_value = MagicMock()
|
||||||
|
result = create_product(payload, session)
|
||||||
|
|
||||||
|
session.add.assert_called_once()
|
||||||
|
session.commit.assert_called_once()
|
||||||
|
|
||||||
|
def test_create_duplicate(self):
|
||||||
|
"""Cree un produit duplique leve 409."""
|
||||||
|
session = MagicMock()
|
||||||
|
session.add = MagicMock()
|
||||||
|
session.commit = MagicMock(side_effect=IntegrityError("duplicate", {}, None))
|
||||||
|
session.rollback = MagicMock()
|
||||||
|
|
||||||
|
payload = ProductCreate(
|
||||||
|
source="amazon",
|
||||||
|
reference="DUPE",
|
||||||
|
url="https://amazon.fr/dp/DUPE",
|
||||||
|
title="Duplicate",
|
||||||
|
currency="EUR",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("pricewatch.app.api.main.Product"):
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
create_product(payload, session)
|
||||||
|
assert exc_info.value.status_code == 409
|
||||||
|
|
||||||
|
def test_create_db_error(self):
|
||||||
|
"""Erreur DB leve 500."""
|
||||||
|
session = MagicMock()
|
||||||
|
session.add = MagicMock()
|
||||||
|
session.commit = MagicMock(side_effect=SQLAlchemyError("db error"))
|
||||||
|
session.rollback = MagicMock()
|
||||||
|
|
||||||
|
payload = ProductCreate(
|
||||||
|
source="amazon",
|
||||||
|
reference="ERR",
|
||||||
|
url="https://amazon.fr/dp/ERR",
|
||||||
|
title="Error",
|
||||||
|
currency="EUR",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("pricewatch.app.api.main.Product"):
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
create_product(payload, session)
|
||||||
|
assert exc_info.value.status_code == 500
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetProduct:
|
||||||
|
"""Tests get_product."""
|
||||||
|
|
||||||
|
def test_get_not_found(self):
|
||||||
|
"""Produit non trouve leve 404."""
|
||||||
|
session = MagicMock()
|
||||||
|
mock_query = MagicMock()
|
||||||
|
mock_query.filter.return_value.one_or_none.return_value = None
|
||||||
|
session.query.return_value = mock_query
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
get_product(99999, session)
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdateProduct:
|
||||||
|
"""Tests update_product."""
|
||||||
|
|
||||||
|
def test_update_not_found(self):
|
||||||
|
"""Update produit non trouve leve 404."""
|
||||||
|
session = MagicMock()
|
||||||
|
mock_query = MagicMock()
|
||||||
|
mock_query.filter.return_value.one_or_none.return_value = None
|
||||||
|
session.query.return_value = mock_query
|
||||||
|
|
||||||
|
payload = ProductUpdate(title="Updated")
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
update_product(99999, payload, session)
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
|
|
||||||
|
def test_update_db_error(self):
|
||||||
|
"""Erreur DB lors d'update leve 500."""
|
||||||
|
session = MagicMock()
|
||||||
|
mock_product = MockProduct()
|
||||||
|
mock_query = MagicMock()
|
||||||
|
mock_query.filter.return_value.one_or_none.return_value = mock_product
|
||||||
|
session.query.return_value = mock_query
|
||||||
|
session.commit = MagicMock(side_effect=SQLAlchemyError("error"))
|
||||||
|
session.rollback = MagicMock()
|
||||||
|
|
||||||
|
payload = ProductUpdate(title="Updated")
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
update_product(1, payload, session)
|
||||||
|
assert exc_info.value.status_code == 500
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteProduct:
|
||||||
|
"""Tests delete_product."""
|
||||||
|
|
||||||
|
def test_delete_not_found(self):
|
||||||
|
"""Delete produit non trouve leve 404."""
|
||||||
|
session = MagicMock()
|
||||||
|
mock_query = MagicMock()
|
||||||
|
mock_query.filter.return_value.one_or_none.return_value = None
|
||||||
|
session.query.return_value = mock_query
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
delete_product(99999, session)
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
|
|
||||||
|
def test_delete_success(self):
|
||||||
|
"""Delete produit avec succes."""
|
||||||
|
session = MagicMock()
|
||||||
|
mock_product = MockProduct()
|
||||||
|
mock_query = MagicMock()
|
||||||
|
mock_query.filter.return_value.one_or_none.return_value = mock_product
|
||||||
|
session.query.return_value = mock_query
|
||||||
|
session.delete = MagicMock()
|
||||||
|
session.commit = MagicMock()
|
||||||
|
|
||||||
|
result = delete_product(1, session)
|
||||||
|
assert result == {"status": "deleted"}
|
||||||
|
session.delete.assert_called_once()
|
||||||
|
|
||||||
|
def test_delete_db_error(self):
|
||||||
|
"""Erreur DB lors de delete leve 500."""
|
||||||
|
session = MagicMock()
|
||||||
|
mock_product = MockProduct()
|
||||||
|
mock_query = MagicMock()
|
||||||
|
mock_query.filter.return_value.one_or_none.return_value = mock_product
|
||||||
|
session.query.return_value = mock_query
|
||||||
|
session.delete = MagicMock()
|
||||||
|
session.commit = MagicMock(side_effect=SQLAlchemyError("error"))
|
||||||
|
session.rollback = MagicMock()
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
delete_product(1, session)
|
||||||
|
assert exc_info.value.status_code == 500
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreatePrice:
|
||||||
|
"""Tests create_price."""
|
||||||
|
|
||||||
|
def test_create_price_db_error(self):
|
||||||
|
"""Erreur DB lors de creation prix."""
|
||||||
|
session = MagicMock()
|
||||||
|
session.add = MagicMock()
|
||||||
|
session.commit = MagicMock(side_effect=SQLAlchemyError("error"))
|
||||||
|
session.rollback = MagicMock()
|
||||||
|
|
||||||
|
payload = PriceHistoryCreate(
|
||||||
|
product_id=1,
|
||||||
|
price=99.99,
|
||||||
|
fetch_method="http",
|
||||||
|
fetch_status="success",
|
||||||
|
fetched_at=datetime.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("pricewatch.app.api.main.PriceHistory"):
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
create_price(payload, session)
|
||||||
|
assert exc_info.value.status_code == 500
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdatePrice:
|
||||||
|
"""Tests update_price."""
|
||||||
|
|
||||||
|
def test_update_price_not_found(self):
|
||||||
|
"""Update prix non trouve leve 404."""
|
||||||
|
session = MagicMock()
|
||||||
|
mock_query = MagicMock()
|
||||||
|
mock_query.filter.return_value.one_or_none.return_value = None
|
||||||
|
session.query.return_value = mock_query
|
||||||
|
|
||||||
|
payload = PriceHistoryUpdate(price=149.99)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
update_price(99999, payload, session)
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeletePrice:
|
||||||
|
"""Tests delete_price."""
|
||||||
|
|
||||||
|
def test_delete_price_not_found(self):
|
||||||
|
"""Delete prix non trouve leve 404."""
|
||||||
|
session = MagicMock()
|
||||||
|
mock_query = MagicMock()
|
||||||
|
mock_query.filter.return_value.one_or_none.return_value = None
|
||||||
|
session.query.return_value = mock_query
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
delete_price(99999, session)
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
135
tests/api/test_scraping_logs.py
Normal file
135
tests/api/test_scraping_logs.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
"""Tests API endpoints scraping logs."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
|
from pricewatch.app.api.main import create_log, update_log, delete_log
|
||||||
|
from pricewatch.app.api.schemas import ScrapingLogCreate, ScrapingLogUpdate
|
||||||
|
|
||||||
|
|
||||||
|
class MockScrapingLog:
|
||||||
|
"""Mock ScrapingLog model."""
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
self.id = kwargs.get("id", 1)
|
||||||
|
self.product_id = kwargs.get("product_id")
|
||||||
|
self.url = kwargs.get("url", "https://example.com")
|
||||||
|
self.source = kwargs.get("source", "amazon")
|
||||||
|
self.reference = kwargs.get("reference", "REF123")
|
||||||
|
self.fetch_method = kwargs.get("fetch_method", "http")
|
||||||
|
self.fetch_status = kwargs.get("fetch_status", "success")
|
||||||
|
self.fetched_at = kwargs.get("fetched_at", datetime.now())
|
||||||
|
self.duration_ms = kwargs.get("duration_ms", 1500)
|
||||||
|
self.html_size_bytes = kwargs.get("html_size_bytes", 50000)
|
||||||
|
self.errors = kwargs.get("errors", [])
|
||||||
|
self.notes = kwargs.get("notes", [])
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateLog:
|
||||||
|
"""Tests create_log endpoint."""
|
||||||
|
|
||||||
|
def test_create_log_db_error(self):
|
||||||
|
"""Erreur DB lors de creation log leve 500."""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
session = MagicMock()
|
||||||
|
session.add = MagicMock()
|
||||||
|
session.commit = MagicMock(side_effect=SQLAlchemyError("error"))
|
||||||
|
session.rollback = MagicMock()
|
||||||
|
|
||||||
|
payload = ScrapingLogCreate(
|
||||||
|
url="https://amazon.fr/dp/TEST",
|
||||||
|
source="amazon",
|
||||||
|
reference="TEST123",
|
||||||
|
fetch_method="http",
|
||||||
|
fetch_status="success",
|
||||||
|
fetched_at=datetime.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("pricewatch.app.api.main.ScrapingLog"):
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
create_log(payload, session)
|
||||||
|
assert exc_info.value.status_code == 500
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdateLog:
|
||||||
|
"""Tests update_log endpoint."""
|
||||||
|
|
||||||
|
def test_update_log_not_found(self):
|
||||||
|
"""Update log non trouve leve 404."""
|
||||||
|
session = MagicMock()
|
||||||
|
mock_query = MagicMock()
|
||||||
|
mock_query.filter.return_value.one_or_none.return_value = None
|
||||||
|
session.query.return_value = mock_query
|
||||||
|
|
||||||
|
payload = ScrapingLogUpdate(fetch_status="failed")
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
update_log(99999, payload, session)
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
|
|
||||||
|
def test_update_log_db_error(self):
|
||||||
|
"""Erreur DB lors d'update log leve 500."""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
session = MagicMock()
|
||||||
|
mock_log = MockScrapingLog()
|
||||||
|
mock_query = MagicMock()
|
||||||
|
mock_query.filter.return_value.one_or_none.return_value = mock_log
|
||||||
|
session.query.return_value = mock_query
|
||||||
|
session.commit = MagicMock(side_effect=SQLAlchemyError("error"))
|
||||||
|
session.rollback = MagicMock()
|
||||||
|
|
||||||
|
payload = ScrapingLogUpdate(fetch_status="failed")
|
||||||
|
|
||||||
|
with patch("pricewatch.app.api.main._log_to_out"):
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
update_log(1, payload, session)
|
||||||
|
assert exc_info.value.status_code == 500
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteLog:
|
||||||
|
"""Tests delete_log endpoint."""
|
||||||
|
|
||||||
|
def test_delete_log_not_found(self):
|
||||||
|
"""Delete log non trouve leve 404."""
|
||||||
|
session = MagicMock()
|
||||||
|
mock_query = MagicMock()
|
||||||
|
mock_query.filter.return_value.one_or_none.return_value = None
|
||||||
|
session.query.return_value = mock_query
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
delete_log(99999, session)
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
|
|
||||||
|
def test_delete_log_success(self):
|
||||||
|
"""Delete log avec succes."""
|
||||||
|
session = MagicMock()
|
||||||
|
mock_log = MockScrapingLog()
|
||||||
|
mock_query = MagicMock()
|
||||||
|
mock_query.filter.return_value.one_or_none.return_value = mock_log
|
||||||
|
session.query.return_value = mock_query
|
||||||
|
session.delete = MagicMock()
|
||||||
|
session.commit = MagicMock()
|
||||||
|
|
||||||
|
result = delete_log(1, session)
|
||||||
|
assert result == {"status": "deleted"}
|
||||||
|
session.delete.assert_called_once()
|
||||||
|
|
||||||
|
def test_delete_log_db_error(self):
|
||||||
|
"""Erreur DB lors de delete log leve 500."""
|
||||||
|
session = MagicMock()
|
||||||
|
mock_log = MockScrapingLog()
|
||||||
|
mock_query = MagicMock()
|
||||||
|
mock_query.filter.return_value.one_or_none.return_value = mock_log
|
||||||
|
session.query.return_value = mock_query
|
||||||
|
session.delete = MagicMock()
|
||||||
|
session.commit = MagicMock(side_effect=SQLAlchemyError("error"))
|
||||||
|
session.rollback = MagicMock()
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
delete_log(1, session)
|
||||||
|
assert exc_info.value.status_code == 500
|
||||||
159
tests/api/test_webhooks_funcs.py
Normal file
159
tests/api/test_webhooks_funcs.py
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
"""Tests API endpoints webhooks."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||||
|
|
||||||
|
from pricewatch.app.api.main import (
|
||||||
|
list_webhooks,
|
||||||
|
create_webhook,
|
||||||
|
update_webhook,
|
||||||
|
delete_webhook,
|
||||||
|
)
|
||||||
|
from pricewatch.app.api.schemas import WebhookCreate, WebhookUpdate
|
||||||
|
|
||||||
|
|
||||||
|
class MockWebhook:
|
||||||
|
"""Mock Webhook model."""
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
self.id = kwargs.get("id", 1)
|
||||||
|
self.url = kwargs.get("url", "https://example.com/webhook")
|
||||||
|
self.events = kwargs.get("events", ["price_change", "stock_change"])
|
||||||
|
self.active = kwargs.get("active", True)
|
||||||
|
self.created_at = kwargs.get("created_at", datetime.now())
|
||||||
|
self.last_triggered_at = kwargs.get("last_triggered_at")
|
||||||
|
|
||||||
|
|
||||||
|
class TestListWebhooks:
|
||||||
|
"""Tests list_webhooks endpoint."""
|
||||||
|
|
||||||
|
def test_list_webhooks_empty(self):
|
||||||
|
"""Liste vide de webhooks."""
|
||||||
|
session = MagicMock()
|
||||||
|
mock_query = MagicMock()
|
||||||
|
mock_query.all.return_value = []
|
||||||
|
session.query.return_value = mock_query
|
||||||
|
|
||||||
|
with patch("pricewatch.app.api.main._webhook_to_out") as mock_to_out:
|
||||||
|
result = list_webhooks(session=session)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateWebhook:
|
||||||
|
"""Tests create_webhook endpoint."""
|
||||||
|
|
||||||
|
def test_create_webhook_integrity_error(self):
|
||||||
|
"""Erreur d'integrite lors de creation webhook leve 500."""
|
||||||
|
# Note: le code actuel ne distingue pas IntegrityError de SQLAlchemyError
|
||||||
|
session = MagicMock()
|
||||||
|
session.add = MagicMock()
|
||||||
|
session.commit = MagicMock(side_effect=IntegrityError("duplicate", {}, None))
|
||||||
|
session.rollback = MagicMock()
|
||||||
|
|
||||||
|
payload = WebhookCreate(
|
||||||
|
event="price_change",
|
||||||
|
url="https://example.com/webhook",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("pricewatch.app.api.main.Webhook"):
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
create_webhook(payload, session)
|
||||||
|
assert exc_info.value.status_code == 500
|
||||||
|
|
||||||
|
def test_create_webhook_db_error(self):
|
||||||
|
"""Erreur DB lors de creation webhook leve 500."""
|
||||||
|
session = MagicMock()
|
||||||
|
session.add = MagicMock()
|
||||||
|
session.commit = MagicMock(side_effect=SQLAlchemyError("error"))
|
||||||
|
session.rollback = MagicMock()
|
||||||
|
|
||||||
|
payload = WebhookCreate(
|
||||||
|
event="price_change",
|
||||||
|
url="https://example.com/webhook",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("pricewatch.app.api.main.Webhook"):
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
create_webhook(payload, session)
|
||||||
|
assert exc_info.value.status_code == 500
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdateWebhook:
|
||||||
|
"""Tests update_webhook endpoint."""
|
||||||
|
|
||||||
|
def test_update_webhook_not_found(self):
|
||||||
|
"""Update webhook non trouve leve 404."""
|
||||||
|
session = MagicMock()
|
||||||
|
mock_query = MagicMock()
|
||||||
|
mock_query.filter.return_value.one_or_none.return_value = None
|
||||||
|
session.query.return_value = mock_query
|
||||||
|
|
||||||
|
payload = WebhookUpdate(active=False)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
update_webhook(99999, payload, session)
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
|
|
||||||
|
def test_update_webhook_db_error(self):
|
||||||
|
"""Erreur DB lors d'update webhook leve 500."""
|
||||||
|
session = MagicMock()
|
||||||
|
mock_webhook = MockWebhook()
|
||||||
|
mock_query = MagicMock()
|
||||||
|
mock_query.filter.return_value.one_or_none.return_value = mock_webhook
|
||||||
|
session.query.return_value = mock_query
|
||||||
|
session.commit = MagicMock(side_effect=SQLAlchemyError("error"))
|
||||||
|
session.rollback = MagicMock()
|
||||||
|
|
||||||
|
payload = WebhookUpdate(active=False)
|
||||||
|
|
||||||
|
with patch("pricewatch.app.api.main._webhook_to_out"):
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
update_webhook(1, payload, session)
|
||||||
|
assert exc_info.value.status_code == 500
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteWebhook:
|
||||||
|
"""Tests delete_webhook endpoint."""
|
||||||
|
|
||||||
|
def test_delete_webhook_not_found(self):
|
||||||
|
"""Delete webhook non trouve leve 404."""
|
||||||
|
session = MagicMock()
|
||||||
|
mock_query = MagicMock()
|
||||||
|
mock_query.filter.return_value.one_or_none.return_value = None
|
||||||
|
session.query.return_value = mock_query
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
delete_webhook(99999, session)
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
|
|
||||||
|
def test_delete_webhook_success(self):
|
||||||
|
"""Delete webhook avec succes."""
|
||||||
|
session = MagicMock()
|
||||||
|
mock_webhook = MockWebhook()
|
||||||
|
mock_query = MagicMock()
|
||||||
|
mock_query.filter.return_value.one_or_none.return_value = mock_webhook
|
||||||
|
session.query.return_value = mock_query
|
||||||
|
session.delete = MagicMock()
|
||||||
|
session.commit = MagicMock()
|
||||||
|
|
||||||
|
result = delete_webhook(1, session)
|
||||||
|
assert result == {"status": "deleted"}
|
||||||
|
session.delete.assert_called_once()
|
||||||
|
|
||||||
|
def test_delete_webhook_db_error(self):
|
||||||
|
"""Erreur DB lors de delete webhook leve 500."""
|
||||||
|
session = MagicMock()
|
||||||
|
mock_webhook = MockWebhook()
|
||||||
|
mock_query = MagicMock()
|
||||||
|
mock_query.filter.return_value.one_or_none.return_value = mock_webhook
|
||||||
|
session.query.return_value = mock_query
|
||||||
|
session.delete = MagicMock()
|
||||||
|
session.commit = MagicMock(side_effect=SQLAlchemyError("error"))
|
||||||
|
session.rollback = MagicMock()
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
delete_webhook(1, session)
|
||||||
|
assert exc_info.value.status_code == 500
|
||||||
BIN
tests/cli/__pycache__/test_detect.cpython-313-pytest-9.0.2.pyc
Normal file
BIN
tests/cli/__pycache__/test_detect.cpython-313-pytest-9.0.2.pyc
Normal file
Binary file not shown.
BIN
tests/cli/__pycache__/test_doctor.cpython-313-pytest-9.0.2.pyc
Normal file
BIN
tests/cli/__pycache__/test_doctor.cpython-313-pytest-9.0.2.pyc
Normal file
Binary file not shown.
BIN
tests/cli/__pycache__/test_fetch.cpython-313-pytest-9.0.2.pyc
Normal file
BIN
tests/cli/__pycache__/test_fetch.cpython-313-pytest-9.0.2.pyc
Normal file
Binary file not shown.
BIN
tests/cli/__pycache__/test_parse.cpython-313-pytest-9.0.2.pyc
Normal file
BIN
tests/cli/__pycache__/test_parse.cpython-313-pytest-9.0.2.pyc
Normal file
Binary file not shown.
Binary file not shown.
42
tests/cli/test_detect.py
Normal file
42
tests/cli/test_detect.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"""Tests pour la commande CLI detect."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
from pricewatch.app.cli.main import app
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
|
||||||
|
class TestDetectCommand:
|
||||||
|
"""Tests pour la commande detect."""
|
||||||
|
|
||||||
|
def test_detect_amazon_url(self):
|
||||||
|
"""Detect doit identifier une URL Amazon."""
|
||||||
|
result = runner.invoke(app, ["detect", "https://www.amazon.fr/dp/B08N5WRWNW"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "amazon" in result.stdout.lower()
|
||||||
|
assert "B08N5WRWNW" in result.stdout
|
||||||
|
|
||||||
|
def test_detect_cdiscount_url(self):
|
||||||
|
"""Detect doit identifier une URL Cdiscount."""
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
"detect",
|
||||||
|
"https://www.cdiscount.com/informatique/f-10709-tuf608umrv004.html",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "cdiscount" in result.stdout.lower()
|
||||||
|
|
||||||
|
def test_detect_unknown_url(self):
|
||||||
|
"""Detect doit echouer pour une URL inconnue."""
|
||||||
|
result = runner.invoke(app, ["detect", "https://www.unknown-store.com/product"])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "aucun store" in result.stdout.lower()
|
||||||
|
|
||||||
|
def test_detect_invalid_url(self):
|
||||||
|
"""Detect doit echouer pour une URL invalide."""
|
||||||
|
result = runner.invoke(app, ["detect", "not-a-valid-url"])
|
||||||
|
assert result.exit_code == 1
|
||||||
36
tests/cli/test_doctor.py
Normal file
36
tests/cli/test_doctor.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""Tests pour la commande CLI doctor."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
from pricewatch.app.cli.main import app
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
|
||||||
|
class TestDoctorCommand:
|
||||||
|
"""Tests pour la commande doctor."""
|
||||||
|
|
||||||
|
def test_doctor_success(self):
|
||||||
|
"""Doctor doit afficher le statut de l'installation."""
|
||||||
|
result = runner.invoke(app, ["doctor"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "PriceWatch Doctor" in result.stdout
|
||||||
|
assert "Python" in result.stdout
|
||||||
|
# "prêt" avec accent
|
||||||
|
assert "prêt" in result.stdout.lower() or "ready" in result.stdout.lower()
|
||||||
|
|
||||||
|
def test_doctor_shows_dependencies(self):
|
||||||
|
"""Doctor doit lister les dependances."""
|
||||||
|
result = runner.invoke(app, ["doctor"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "typer" in result.stdout.lower()
|
||||||
|
assert "pydantic" in result.stdout.lower()
|
||||||
|
assert "playwright" in result.stdout.lower()
|
||||||
|
|
||||||
|
def test_doctor_shows_stores(self):
|
||||||
|
"""Doctor doit lister les stores disponibles."""
|
||||||
|
result = runner.invoke(app, ["doctor"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "amazon" in result.stdout.lower()
|
||||||
|
assert "cdiscount" in result.stdout.lower()
|
||||||
99
tests/cli/test_fetch.py
Normal file
99
tests/cli/test_fetch.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"""Tests pour la commande CLI fetch."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
from pricewatch.app.cli.main import app
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchCommand:
|
||||||
|
"""Tests pour la commande fetch."""
|
||||||
|
|
||||||
|
def test_fetch_conflicting_options(self):
|
||||||
|
"""Fetch doit echouer si --http et --playwright sont specifies."""
|
||||||
|
result = runner.invoke(
|
||||||
|
app, ["fetch", "https://example.com", "--http", "--playwright"]
|
||||||
|
)
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "impossible" in result.stdout.lower()
|
||||||
|
|
||||||
|
@patch("pricewatch.app.cli.main.fetch_http")
|
||||||
|
def test_fetch_http_success(self, mock_fetch: MagicMock):
|
||||||
|
"""Fetch HTTP doit afficher le resultat."""
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.success = True
|
||||||
|
mock_result.html = "<html>test</html>"
|
||||||
|
mock_result.status_code = 200
|
||||||
|
mock_result.duration_ms = 150
|
||||||
|
mock_fetch.return_value = mock_result
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["fetch", "https://example.com", "--http"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Succes" in result.stdout or "✓" in result.stdout
|
||||||
|
assert "150" in result.stdout
|
||||||
|
|
||||||
|
@patch("pricewatch.app.cli.main.fetch_http")
|
||||||
|
def test_fetch_http_failure(self, mock_fetch: MagicMock):
|
||||||
|
"""Fetch HTTP doit signaler l'echec."""
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.success = False
|
||||||
|
mock_result.error = "Connection refused"
|
||||||
|
mock_fetch.return_value = mock_result
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["fetch", "https://example.com", "--http"])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "Connection refused" in result.stdout
|
||||||
|
|
||||||
|
@patch("pricewatch.app.cli.main.fetch_playwright")
|
||||||
|
def test_fetch_playwright_success(self, mock_fetch: MagicMock):
|
||||||
|
"""Fetch Playwright doit afficher le resultat."""
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.success = True
|
||||||
|
mock_result.html = "<html>test playwright</html>"
|
||||||
|
mock_result.duration_ms = 2500
|
||||||
|
mock_fetch.return_value = mock_result
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["fetch", "https://example.com", "--playwright"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Succes" in result.stdout or "✓" in result.stdout
|
||||||
|
assert "2500" in result.stdout
|
||||||
|
|
||||||
|
@patch("pricewatch.app.cli.main.fetch_playwright")
|
||||||
|
def test_fetch_playwright_failure(self, mock_fetch: MagicMock):
|
||||||
|
"""Fetch Playwright doit signaler l'echec."""
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.success = False
|
||||||
|
mock_result.error = "Timeout waiting for page"
|
||||||
|
mock_fetch.return_value = mock_result
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["fetch", "https://example.com", "--playwright"])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "Timeout" in result.stdout
|
||||||
|
|
||||||
|
@patch("pricewatch.app.cli.main.fetch_playwright")
|
||||||
|
def test_fetch_default_is_playwright(self, mock_fetch: MagicMock):
|
||||||
|
"""Fetch sans option utilise Playwright par defaut."""
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.success = True
|
||||||
|
mock_result.html = "<html>test</html>"
|
||||||
|
mock_result.duration_ms = 1000
|
||||||
|
mock_fetch.return_value = mock_result
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["fetch", "https://example.com"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
mock_fetch.assert_called_once()
|
||||||
|
|
||||||
|
@patch("pricewatch.app.cli.main.fetch_playwright")
|
||||||
|
def test_fetch_with_debug(self, mock_fetch: MagicMock):
|
||||||
|
"""Fetch doit fonctionner avec --debug."""
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.success = True
|
||||||
|
mock_result.html = "<html>test</html>"
|
||||||
|
mock_result.duration_ms = 1000
|
||||||
|
mock_fetch.return_value = mock_result
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["fetch", "https://example.com", "--debug"])
|
||||||
|
assert result.exit_code == 0
|
||||||
99
tests/cli/test_parse.py
Normal file
99
tests/cli/test_parse.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"""Tests pour la commande CLI parse."""
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
from pricewatch.app.cli.main import app
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseCommand:
|
||||||
|
"""Tests pour la commande parse."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def amazon_html_file(self, tmp_path: Path) -> Path:
|
||||||
|
"""Cree un fichier HTML Amazon temporaire."""
|
||||||
|
html = """
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<span id="productTitle">Test Product</span>
|
||||||
|
<span class="a-price-whole">299,99 €</span>
|
||||||
|
<div id="availability">
|
||||||
|
<span>En stock</span>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
file_path = tmp_path / "amazon_test.html"
|
||||||
|
file_path.write_text(html, encoding="utf-8")
|
||||||
|
return file_path
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def cdiscount_html_file(self, tmp_path: Path) -> Path:
|
||||||
|
"""Cree un fichier HTML Cdiscount temporaire."""
|
||||||
|
html = """
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@type": "Product",
|
||||||
|
"name": "Produit Cdiscount",
|
||||||
|
"offers": {"price": "199.99", "priceCurrency": "EUR"}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1 data-e2e="title">Produit Cdiscount</h1>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
file_path = tmp_path / "cdiscount_test.html"
|
||||||
|
file_path.write_text(html, encoding="utf-8")
|
||||||
|
return file_path
|
||||||
|
|
||||||
|
def test_parse_amazon_success(self, amazon_html_file: Path):
|
||||||
|
"""Parse doit extraire les donnees d'un HTML Amazon."""
|
||||||
|
result = runner.invoke(
|
||||||
|
app, ["parse", "amazon", "--in", str(amazon_html_file)]
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Test Product" in result.stdout
|
||||||
|
assert "299" in result.stdout
|
||||||
|
|
||||||
|
def test_parse_cdiscount_success(self, cdiscount_html_file: Path):
|
||||||
|
"""Parse doit extraire les donnees d'un HTML Cdiscount."""
|
||||||
|
result = runner.invoke(
|
||||||
|
app, ["parse", "cdiscount", "--in", str(cdiscount_html_file)]
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Produit Cdiscount" in result.stdout
|
||||||
|
assert "199" in result.stdout
|
||||||
|
|
||||||
|
def test_parse_unknown_store(self, amazon_html_file: Path):
|
||||||
|
"""Parse doit echouer pour un store inconnu."""
|
||||||
|
result = runner.invoke(
|
||||||
|
app, ["parse", "unknown_store", "--in", str(amazon_html_file)]
|
||||||
|
)
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "inconnu" in result.stdout.lower()
|
||||||
|
|
||||||
|
def test_parse_with_debug(self, amazon_html_file: Path):
|
||||||
|
"""Parse doit fonctionner avec --debug."""
|
||||||
|
result = runner.invoke(
|
||||||
|
app, ["parse", "amazon", "--in", str(amazon_html_file), "--debug"]
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
def test_parse_shows_fields(self, amazon_html_file: Path):
|
||||||
|
"""Parse doit afficher les champs extraits."""
|
||||||
|
result = runner.invoke(
|
||||||
|
app, ["parse", "amazon", "--in", str(amazon_html_file)]
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Titre" in result.stdout
|
||||||
|
assert "Prix" in result.stdout
|
||||||
|
assert "Stock" in result.stdout
|
||||||
258
tests/cli/test_run_command.py
Normal file
258
tests/cli/test_run_command.py
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
"""Tests pour la commande CLI run."""
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
from pricewatch.app.cli.main import app
|
||||||
|
from pricewatch.app.core.schema import ProductSnapshot, DebugInfo, DebugStatus, FetchMethod
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def yaml_config(tmp_path: Path) -> Path:
|
||||||
|
"""Cree un fichier YAML de config temporaire."""
|
||||||
|
yaml_content = """
|
||||||
|
urls:
|
||||||
|
- "https://www.amazon.fr/dp/B08N5WRWNW"
|
||||||
|
options:
|
||||||
|
use_playwright: false
|
||||||
|
force_playwright: false
|
||||||
|
headful: false
|
||||||
|
save_html: false
|
||||||
|
save_screenshot: false
|
||||||
|
timeout_ms: 30000
|
||||||
|
"""
|
||||||
|
file_path = tmp_path / "test_config.yaml"
|
||||||
|
file_path.write_text(yaml_content, encoding="utf-8")
|
||||||
|
return file_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def output_json(tmp_path: Path) -> Path:
|
||||||
|
"""Chemin pour le fichier JSON de sortie."""
|
||||||
|
return tmp_path / "output.json"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunCommand:
|
||||||
|
"""Tests pour la commande run."""
|
||||||
|
|
||||||
|
@patch("pricewatch.app.cli.main.fetch_http")
|
||||||
|
def test_run_http_success(self, mock_fetch, yaml_config, output_json):
|
||||||
|
"""Run avec HTTP reussi."""
|
||||||
|
# Mock HTTP fetch
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.success = True
|
||||||
|
mock_result.html = """
|
||||||
|
<html><body>
|
||||||
|
<span id="productTitle">Test Product</span>
|
||||||
|
<span class="a-price-whole">299,99 €</span>
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
mock_result.error = None
|
||||||
|
mock_fetch.return_value = mock_result
|
||||||
|
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
["run", "--yaml", str(yaml_config), "--out", str(output_json), "--no-db"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert output_json.exists()
|
||||||
|
|
||||||
|
@patch("pricewatch.app.cli.main.fetch_http")
|
||||||
|
@patch("pricewatch.app.cli.main.fetch_playwright")
|
||||||
|
def test_run_http_fail_playwright_fallback(
|
||||||
|
self, mock_pw, mock_http, yaml_config, output_json
|
||||||
|
):
|
||||||
|
"""Run avec fallback Playwright quand HTTP echoue."""
|
||||||
|
# Mock HTTP fail
|
||||||
|
mock_http_result = MagicMock()
|
||||||
|
mock_http_result.success = False
|
||||||
|
mock_http_result.error = "403 Forbidden"
|
||||||
|
mock_http.return_value = mock_http_result
|
||||||
|
|
||||||
|
# Mock Playwright success
|
||||||
|
mock_pw_result = MagicMock()
|
||||||
|
mock_pw_result.success = True
|
||||||
|
mock_pw_result.html = """
|
||||||
|
<html><body>
|
||||||
|
<span id="productTitle">Playwright Product</span>
|
||||||
|
<span class="a-price-whole">199,99 €</span>
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
mock_pw_result.screenshot = None
|
||||||
|
mock_pw.return_value = mock_pw_result
|
||||||
|
|
||||||
|
# Modifier config pour activer playwright
|
||||||
|
yaml_content = """
|
||||||
|
urls:
|
||||||
|
- "https://www.amazon.fr/dp/B08N5WRWNW"
|
||||||
|
options:
|
||||||
|
use_playwright: true
|
||||||
|
force_playwright: false
|
||||||
|
headful: false
|
||||||
|
save_html: false
|
||||||
|
save_screenshot: false
|
||||||
|
timeout_ms: 30000
|
||||||
|
"""
|
||||||
|
yaml_config.write_text(yaml_content, encoding="utf-8")
|
||||||
|
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
["run", "--yaml", str(yaml_config), "--out", str(output_json), "--no-db"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
mock_pw.assert_called()
|
||||||
|
|
||||||
|
@patch("pricewatch.app.cli.main.fetch_http")
|
||||||
|
def test_run_http_fail_no_playwright(self, mock_http, yaml_config, output_json):
|
||||||
|
"""Run avec HTTP echoue sans Playwright."""
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.success = False
|
||||||
|
mock_result.error = "Connection refused"
|
||||||
|
mock_http.return_value = mock_result
|
||||||
|
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
["run", "--yaml", str(yaml_config), "--out", str(output_json), "--no-db"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Doit quand meme creer le fichier JSON (avec snapshot failed)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert output_json.exists()
|
||||||
|
|
||||||
|
def test_run_invalid_yaml(self, tmp_path, output_json):
|
||||||
|
"""Run avec YAML invalide echoue."""
|
||||||
|
yaml_file = tmp_path / "invalid.yaml"
|
||||||
|
yaml_file.write_text("invalid: [yaml: content", encoding="utf-8")
|
||||||
|
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
["run", "--yaml", str(yaml_file), "--out", str(output_json)],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 1
|
||||||
|
|
||||||
|
def test_run_with_debug(self, yaml_config, output_json):
|
||||||
|
"""Run avec --debug active les logs."""
|
||||||
|
with patch("pricewatch.app.cli.main.fetch_http") as mock_fetch:
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.success = True
|
||||||
|
mock_result.html = "<html><body>Test</body></html>"
|
||||||
|
mock_fetch.return_value = mock_result
|
||||||
|
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
"run",
|
||||||
|
"--yaml",
|
||||||
|
str(yaml_config),
|
||||||
|
"--out",
|
||||||
|
str(output_json),
|
||||||
|
"--debug",
|
||||||
|
"--no-db",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
@patch("pricewatch.app.cli.main.fetch_playwright")
|
||||||
|
def test_run_force_playwright(self, mock_pw, tmp_path, output_json):
|
||||||
|
"""Run avec force_playwright skip HTTP."""
|
||||||
|
yaml_content = """
|
||||||
|
urls:
|
||||||
|
- "https://www.amazon.fr/dp/B08N5WRWNW"
|
||||||
|
options:
|
||||||
|
use_playwright: true
|
||||||
|
force_playwright: true
|
||||||
|
headful: false
|
||||||
|
save_html: false
|
||||||
|
save_screenshot: false
|
||||||
|
timeout_ms: 30000
|
||||||
|
"""
|
||||||
|
yaml_file = tmp_path / "force_pw.yaml"
|
||||||
|
yaml_file.write_text(yaml_content, encoding="utf-8")
|
||||||
|
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.success = True
|
||||||
|
mock_result.html = "<html><body>PW content</body></html>"
|
||||||
|
mock_result.screenshot = None
|
||||||
|
mock_pw.return_value = mock_result
|
||||||
|
|
||||||
|
with patch("pricewatch.app.cli.main.fetch_http") as mock_http:
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
["run", "--yaml", str(yaml_file), "--out", str(output_json), "--no-db"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# HTTP ne doit pas etre appele
|
||||||
|
mock_http.assert_not_called()
|
||||||
|
mock_pw.assert_called()
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
@patch("pricewatch.app.cli.main.fetch_http")
|
||||||
|
def test_run_unknown_store(self, mock_fetch, tmp_path, output_json):
|
||||||
|
"""Run avec URL de store inconnu."""
|
||||||
|
yaml_content = """
|
||||||
|
urls:
|
||||||
|
- "https://www.unknown-store.com/product/123"
|
||||||
|
options:
|
||||||
|
use_playwright: false
|
||||||
|
"""
|
||||||
|
yaml_file = tmp_path / "unknown.yaml"
|
||||||
|
yaml_file.write_text(yaml_content, encoding="utf-8")
|
||||||
|
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
["run", "--yaml", str(yaml_file), "--out", str(output_json), "--no-db"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Doit continuer sans crash
|
||||||
|
assert result.exit_code == 0
|
||||||
|
# HTTP ne doit pas etre appele (store non trouve)
|
||||||
|
mock_fetch.assert_not_called()
|
||||||
|
|
||||||
|
@patch("pricewatch.app.cli.main.fetch_http")
|
||||||
|
@patch("pricewatch.app.cli.main.fetch_playwright")
|
||||||
|
def test_run_with_save_screenshot(self, mock_pw, mock_http, tmp_path, output_json):
|
||||||
|
"""Run avec save_screenshot."""
|
||||||
|
yaml_content = """
|
||||||
|
urls:
|
||||||
|
- "https://www.amazon.fr/dp/B08N5WRWNW"
|
||||||
|
options:
|
||||||
|
use_playwright: true
|
||||||
|
force_playwright: false
|
||||||
|
save_screenshot: true
|
||||||
|
timeout_ms: 30000
|
||||||
|
"""
|
||||||
|
yaml_file = tmp_path / "screenshot.yaml"
|
||||||
|
yaml_file.write_text(yaml_content, encoding="utf-8")
|
||||||
|
|
||||||
|
# HTTP fail
|
||||||
|
mock_http_result = MagicMock()
|
||||||
|
mock_http_result.success = False
|
||||||
|
mock_http_result.error = "blocked"
|
||||||
|
mock_http.return_value = mock_http_result
|
||||||
|
|
||||||
|
# PW success avec screenshot
|
||||||
|
mock_pw_result = MagicMock()
|
||||||
|
mock_pw_result.success = True
|
||||||
|
mock_pw_result.html = "<html><body>content</body></html>"
|
||||||
|
mock_pw_result.screenshot = b"fake_png_data"
|
||||||
|
mock_pw.return_value = mock_pw_result
|
||||||
|
|
||||||
|
with patch("pricewatch.app.core.io.save_debug_screenshot") as mock_save:
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
["run", "--yaml", str(yaml_file), "--out", str(output_json), "--no-db"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
# Le screenshot doit etre sauvegarde si present
|
||||||
|
mock_save.assert_called()
|
||||||
BIN
tests/stores/__pycache__/test_cdiscount_fixtures.cpython-313-pytest-9.0.2.pyc
Executable file → Normal file
BIN
tests/stores/__pycache__/test_cdiscount_fixtures.cpython-313-pytest-9.0.2.pyc
Executable file → Normal file
Binary file not shown.
Binary file not shown.
@@ -171,7 +171,25 @@ class TestCdiscountRealFixtures:
|
|||||||
assert isinstance(snapshot.price, float)
|
assert isinstance(snapshot.price, float)
|
||||||
assert snapshot.price > 0
|
assert snapshot.price > 0
|
||||||
# Le prix doit avoir maximum 2 décimales
|
# Le prix doit avoir maximum 2 décimales
|
||||||
assert snapshot.price == round(snapshot.price, 2)
|
assert snapshot.price == round(snapshot.price, 2)
|
||||||
|
|
||||||
|
def test_parse_tuf608umrv004_price_value(self, store, fixture_tuf608umrv004):
|
||||||
|
"""Le prix doit correspondre à 1199,99 €."""
|
||||||
|
url = "https://www.cdiscount.com/informatique/.../f-10709-tuf608umrv004.html"
|
||||||
|
snapshot = store.parse(fixture_tuf608umrv004, url)
|
||||||
|
assert snapshot.price == 1199.99
|
||||||
|
|
||||||
|
def test_parse_tuf608umrv004_category_and_msrp(
|
||||||
|
self, store, fixture_tuf608umrv004
|
||||||
|
):
|
||||||
|
"""La fixture ASUS doit fournir une catégorie et un prix conseillé."""
|
||||||
|
url = "https://www.cdiscount.com/informatique/.../f-10709-tuf608umrv004.html"
|
||||||
|
snapshot = store.parse(fixture_tuf608umrv004, url)
|
||||||
|
assert snapshot.category
|
||||||
|
assert "Ordinateur" in snapshot.category or "Portable" in snapshot.category
|
||||||
|
assert snapshot.msrp is not None
|
||||||
|
if snapshot.price:
|
||||||
|
assert snapshot.msrp >= snapshot.price
|
||||||
|
|
||||||
def test_parse_a128902_price_format(self, store, fixture_a128902):
|
def test_parse_a128902_price_format(self, store, fixture_a128902):
|
||||||
"""Parse fixture a128902 - le prix doit être un float valide."""
|
"""Parse fixture a128902 - le prix doit être un float valide."""
|
||||||
|
|||||||
@@ -27,3 +27,7 @@ def test_parse_price_without_decimal():
|
|||||||
|
|
||||||
def test_parse_price_with_currency():
|
def test_parse_price_with_currency():
|
||||||
assert parse_price_text("EUR 1 259,00") == 1259.00
|
assert parse_price_text("EUR 1 259,00") == 1259.00
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_price_with_cents_after_currency_symbol():
|
||||||
|
assert parse_price_text("1199 €99") == 1199.99
|
||||||
|
|||||||
1532
webui/src/App.vue
1532
webui/src/App.vue
File diff suppressed because it is too large
Load Diff
4
webui/src/assets/stores/aliexpress.svg
Normal file
4
webui/src/assets/stores/aliexpress.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<rect width="64" height="64" rx="12" fill="#1f1f1f" />
|
||||||
|
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="#ebdbb2" font-family="Space Mono, monospace" font-size="20">AE</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 273 B |
4
webui/src/assets/stores/amazon.svg
Normal file
4
webui/src/assets/stores/amazon.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<rect width="64" height="64" rx="12" fill="#1f1f1f" />
|
||||||
|
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="#ebdbb2" font-family="Space Mono, monospace" font-size="20">AM</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 273 B |
4
webui/src/assets/stores/backmarket.svg
Normal file
4
webui/src/assets/stores/backmarket.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<rect width="64" height="64" rx="12" fill="#1f1f1f" />
|
||||||
|
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="#ebdbb2" font-family="Space Mono, monospace" font-size="20">BM</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 273 B |
4
webui/src/assets/stores/cdiscount.svg
Normal file
4
webui/src/assets/stores/cdiscount.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<rect width="64" height="64" rx="12" fill="#1f1f1f" />
|
||||||
|
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="#ebdbb2" font-family="Space Mono, monospace" font-size="20">CD</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 273 B |
280
webui/src/components/MiniLineChart.vue
Normal file
280
webui/src/components/MiniLineChart.vue
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, PropType } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
points: {
|
||||||
|
type: Array as PropType<Array<{ t: number | string; v: number }>>,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
width: {
|
||||||
|
type: Number,
|
||||||
|
default: 280,
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
type: Number,
|
||||||
|
default: 140,
|
||||||
|
},
|
||||||
|
yTicks: {
|
||||||
|
type: Number,
|
||||||
|
default: 4,
|
||||||
|
},
|
||||||
|
xTicks: {
|
||||||
|
type: Number,
|
||||||
|
default: 4,
|
||||||
|
},
|
||||||
|
formatY: {
|
||||||
|
type: Function as PropType<(value: number) => string>,
|
||||||
|
default: (value: number) => new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(value),
|
||||||
|
},
|
||||||
|
formatX: {
|
||||||
|
type: Function as PropType<(value: number | string) => string>,
|
||||||
|
default: (value: number | string) => String(value),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const margins = {
|
||||||
|
left: 44,
|
||||||
|
right: 8,
|
||||||
|
top: 8,
|
||||||
|
bottom: 22,
|
||||||
|
};
|
||||||
|
|
||||||
|
const validPoints = computed(() =>
|
||||||
|
props.points
|
||||||
|
.map((item) => {
|
||||||
|
const value = Number(item.v);
|
||||||
|
return {
|
||||||
|
t: item.t,
|
||||||
|
v: Number.isFinite(value) ? value : NaN,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item) => Number.isFinite(item.v))
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasTimestamp = computed(() => {
|
||||||
|
return validPoints.value.every((item) => {
|
||||||
|
if (typeof item.t === "number") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const parsed = Date.parse(String(item.t));
|
||||||
|
return !Number.isNaN(parsed);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const timestamps = computed(() =>
|
||||||
|
validPoints.value.map((item) => {
|
||||||
|
if (typeof item.t === "number") {
|
||||||
|
return item.t;
|
||||||
|
}
|
||||||
|
const parsed = Date.parse(String(item.t));
|
||||||
|
return Number.isNaN(parsed) ? null : parsed;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const yBounds = computed(() => {
|
||||||
|
if (!validPoints.value.length) {
|
||||||
|
return { min: 0, max: 0 };
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartDimensions = computed(() => ({
|
||||||
|
width: props.width - margins.left - margins.right,
|
||||||
|
height: props.height - margins.top - margins.bottom,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const chartPoints = computed(() => {
|
||||||
|
const points = validPoints.value;
|
||||||
|
if (points.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const { min, max } = yBounds.value;
|
||||||
|
const delta = max - min || 1;
|
||||||
|
const { width: chartWidth, height: chartHeight } = chartDimensions.value;
|
||||||
|
const timeRange =
|
||||||
|
hasTimestamp.value && timestamps.value.some((t) => t !== null)
|
||||||
|
? (timestamps.value as number[]).reduce(
|
||||||
|
(acc, cur) => {
|
||||||
|
if (cur === null) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
acc.min = acc.min === null ? cur : Math.min(acc.min, cur);
|
||||||
|
acc.max = acc.max === null ? cur : Math.max(acc.max, cur);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ min: null as number | null, max: null as number | null }
|
||||||
|
)
|
||||||
|
: { min: null, max: null };
|
||||||
|
const times = (timestamps.value as Array<number | null>).map((value, index) => {
|
||||||
|
if (hasTimestamp.value && value !== null && timeRange.min !== null && timeRange.max !== null) {
|
||||||
|
const range = Math.max(timeRange.max - timeRange.min, 1);
|
||||||
|
return (value - timeRange.min) / range;
|
||||||
|
}
|
||||||
|
return points.length > 1 ? index / (points.length - 1) : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return points.map((point, index) => {
|
||||||
|
const x = margins.left + chartWidth * times[index];
|
||||||
|
const normalizedY = 1 - (point.v - min) / delta;
|
||||||
|
const y = margins.top + chartHeight * normalizedY;
|
||||||
|
return { x, y, value: point.v, raw: point.t };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasPoints = computed(() => chartPoints.value.length > 0);
|
||||||
|
|
||||||
|
const linePoints = computed(() => {
|
||||||
|
if (!chartPoints.value.length) {
|
||||||
|
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 yTickValues = computed(() => {
|
||||||
|
const count = Math.max(2, props.yTicks);
|
||||||
|
const { min, max } = yBounds.value;
|
||||||
|
const step = (max - min) / (count - 1 || 1);
|
||||||
|
return Array.from({ length: count }, (_, index) => ({
|
||||||
|
value: min + step * index,
|
||||||
|
position: margins.top + chartDimensions.value.height * (1 - (min + step * index - min) / (max - min || 1)),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const xTickIndices = computed(() => {
|
||||||
|
const points = chartPoints.value;
|
||||||
|
const count = Math.max(2, Math.min(points.length, props.xTicks));
|
||||||
|
if (!points.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Array.from({ length: count }, (_, index) => {
|
||||||
|
const idx = Math.round((points.length - 1) * (index / (count - 1 || 1)));
|
||||||
|
return points[idx];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const xLabels = computed(() => {
|
||||||
|
return xTickIndices.value.map((point) => ({
|
||||||
|
label: props.formatX(point.raw),
|
||||||
|
x: point.x,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedYTicks = computed(() =>
|
||||||
|
yTickValues.value.map((tick) => ({
|
||||||
|
label: props.formatY(tick.value),
|
||||||
|
y: tick.position,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const placeholderLabel = computed(() => "");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mini-line-chart-wrapper">
|
||||||
|
<svg
|
||||||
|
v-if="hasPoints"
|
||||||
|
:width="width"
|
||||||
|
:height="height"
|
||||||
|
:viewBox="`0 0 ${width} ${height}`"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<line
|
||||||
|
:x1="margins.left"
|
||||||
|
:x2="margins.left"
|
||||||
|
:y1="margins.top"
|
||||||
|
:y2="margins.top + chartDimensions.height"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1"
|
||||||
|
opacity="0.35"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
:x1="margins.left"
|
||||||
|
:x2="margins.left + chartDimensions.width"
|
||||||
|
:y1="margins.top + chartDimensions.height"
|
||||||
|
:y2="margins.top + chartDimensions.height"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1"
|
||||||
|
opacity="0.35"
|
||||||
|
/>
|
||||||
|
<g v-for="tick in formattedYTicks" :key="tick.label">
|
||||||
|
<line
|
||||||
|
:x1="margins.left - 6"
|
||||||
|
:x2="margins.left"
|
||||||
|
:y1="tick.y"
|
||||||
|
:y2="tick.y"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1"
|
||||||
|
opacity="0.35"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
:x="margins.left - 10"
|
||||||
|
:y="tick.y + 4"
|
||||||
|
class="text-[10px]"
|
||||||
|
text-anchor="end"
|
||||||
|
:opacity="0.65"
|
||||||
|
>
|
||||||
|
{{ tick.label }}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
<g v-for="label in xLabels" :key="label.label">
|
||||||
|
<line
|
||||||
|
:x1="label.x"
|
||||||
|
:x2="label.x"
|
||||||
|
:y1="margins.top + chartDimensions.height"
|
||||||
|
:y2="margins.top + chartDimensions.height + 6"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1"
|
||||||
|
opacity="0.35"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
:x="label.x"
|
||||||
|
:y="height - 4"
|
||||||
|
class="text-[10px]"
|
||||||
|
text-anchor="middle"
|
||||||
|
:opacity="0.65"
|
||||||
|
>
|
||||||
|
{{ label.label }}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
<polyline
|
||||||
|
:points="polylinePoints"
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="none"
|
||||||
|
stroke-width="2"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
v-for="(point, index) in chartPoints"
|
||||||
|
:key="`${point.x}-${point.y}-${index}`"
|
||||||
|
:cx="point.x"
|
||||||
|
:cy="point.y"
|
||||||
|
r="2"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1"
|
||||||
|
fill="currentColor"
|
||||||
|
:class="{ 'mini-line-chart__point--last': index === chartPoints.length - 1 }"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div v-else class="history-placeholder" aria-hidden="true">
|
||||||
|
{{ placeholderLabel }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
87
webui/src/components/MiniSparkline.vue
Normal file
87
webui/src/components/MiniSparkline.vue
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, PropType } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
points: {
|
||||||
|
type: Array as PropType<number[]>,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
width: {
|
||||||
|
type: Number,
|
||||||
|
default: 280,
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
type: Number,
|
||||||
|
default: 14,
|
||||||
|
},
|
||||||
|
padding: {
|
||||||
|
type: Number,
|
||||||
|
default: 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const validPoints = computed(() =>
|
||||||
|
(props.points || [])
|
||||||
|
.map((value) => (Number.isFinite(value) ? Number(value) : null))
|
||||||
|
.filter((value): value is number => value !== null)
|
||||||
|
);
|
||||||
|
|
||||||
|
const pointRange = computed(() => {
|
||||||
|
const points = validPoints.value;
|
||||||
|
if (points.length === 0) {
|
||||||
|
return { min: 0, max: 1 };
|
||||||
|
}
|
||||||
|
const min = Math.min(...points);
|
||||||
|
const max = Math.max(...points);
|
||||||
|
return { min, max };
|
||||||
|
});
|
||||||
|
|
||||||
|
const svgPoints = computed(() => {
|
||||||
|
const points = validPoints.value;
|
||||||
|
const { min, max } = pointRange.value;
|
||||||
|
if (points.length === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const delta = max - min || 1;
|
||||||
|
const availableWidth = props.width - props.padding * 2;
|
||||||
|
const availableHeight = props.height - props.padding * 2;
|
||||||
|
const step = points.length > 1 ? availableWidth / (points.length - 1) : 0;
|
||||||
|
return points
|
||||||
|
.map((value, index) => {
|
||||||
|
const x = props.padding + step * index;
|
||||||
|
const normalized = (value - min) / delta;
|
||||||
|
const y = props.padding + availableHeight * (1 - normalized);
|
||||||
|
return `${x},${y}`;
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasPoints = computed(() => validPoints.value.length > 1);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mini-sparkline">
|
||||||
|
<svg
|
||||||
|
:width="width"
|
||||||
|
:height="height"
|
||||||
|
:viewBox="`0 0 ${width} ${height}`"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<polyline
|
||||||
|
v-if="hasPoints"
|
||||||
|
:points="svgPoints"
|
||||||
|
class="sparkline-polyline"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
v-else
|
||||||
|
:x1="padding"
|
||||||
|
:y1="height / 2"
|
||||||
|
:x2="width - padding"
|
||||||
|
:y2="height / 2"
|
||||||
|
class="sparkline-polyline"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
90
webui/src/components/PriceHistoryChart.vue
Normal file
90
webui/src/components/PriceHistoryChart.vue
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<template>
|
||||||
|
<div class="price-history-chart panel p-3">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div class="section-title text-sm">Historique</div>
|
||||||
|
<div class="label text-xs">{{ deltaLabel }}</div>
|
||||||
|
</div>
|
||||||
|
<svg class="w-full h-20 mb-2" viewBox="0 0 120 50" preserveAspectRatio="none">
|
||||||
|
<polyline :points="polyPoints" class="sparkline" fill="none" />
|
||||||
|
<circle
|
||||||
|
v-for="(point, index) in svgPoints"
|
||||||
|
:key="`history-detail-point-${index}`"
|
||||||
|
:cx="point.cx"
|
||||||
|
:cy="point.cy"
|
||||||
|
r="1.3"
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div class="grid grid-cols-2 gap-3 text-xs">
|
||||||
|
<div>Actuel<br /><strong>{{ formatPrice(currentPrice) }}</strong></div>
|
||||||
|
<div>Min<br /><strong>{{ formatPrice(minPrice) }}</strong></div>
|
||||||
|
<div>Max<br /><strong>{{ formatPrice(maxPrice) }}</strong></div>
|
||||||
|
<div>Delta<br /><strong>{{ deltaLabel }}</strong></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
history: {
|
||||||
|
type: Array as () => number[],
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
currentPrice: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
minPrice: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
maxPrice: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
deltaLabel: {
|
||||||
|
type: String,
|
||||||
|
default: "—",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const polyPoints = computed(() => {
|
||||||
|
if (!props.history.length) {
|
||||||
|
return "0,40 30,30 60,35 90,25 120,28";
|
||||||
|
}
|
||||||
|
const max = Math.max(...props.history);
|
||||||
|
const min = Math.min(...props.history);
|
||||||
|
const range = max - min || 1;
|
||||||
|
return props.history
|
||||||
|
.map((value, index) => {
|
||||||
|
const x = (index / (props.history.length - 1 || 1)) * 120;
|
||||||
|
const y = 50 - ((value - min) / range) * 50;
|
||||||
|
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
|
});
|
||||||
|
|
||||||
|
const svgPoints = computed(() => {
|
||||||
|
if (!props.history.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const max = Math.max(...props.history);
|
||||||
|
const min = Math.min(...props.history);
|
||||||
|
const range = max - min || 1;
|
||||||
|
return props.history.map((value, index) => {
|
||||||
|
const x = (index / (props.history.length - 1 || 1)) * 120;
|
||||||
|
const y = 50 - ((value - min) / range) * 50;
|
||||||
|
return { cx: x.toFixed(1), cy: y.toFixed(1) };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatPrice = (value: number) => {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return "n/a";
|
||||||
|
}
|
||||||
|
return `${value.toFixed(2)} €`;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
136
webui/src/components/PriceHistoryHover.vue
Normal file
136
webui/src/components/PriceHistoryHover.vue
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body" v-if="visible">
|
||||||
|
<div
|
||||||
|
class="price-history-popup panel p-4 shadow-lg"
|
||||||
|
:style="popupStyle"
|
||||||
|
@mouseenter="keepOpen"
|
||||||
|
@mouseleave="close"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div class="section-title text-sm">Historique 30j</div>
|
||||||
|
</div>
|
||||||
|
<svg class="w-full h-12 mb-2" viewBox="0 0 120 40" preserveAspectRatio="none">
|
||||||
|
<polyline
|
||||||
|
:points="polyPoints"
|
||||||
|
class="sparkline"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
v-for="(point, index) in svgPoints"
|
||||||
|
:key="`history-point-${index}`"
|
||||||
|
:cx="point.cx"
|
||||||
|
:cy="point.cy"
|
||||||
|
r="1.2"
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div class="grid grid-cols-2 gap-3 text-[0.75rem]">
|
||||||
|
<div>Actuel<br /><strong>{{ formatPrice(currentPrice) }}</strong></div>
|
||||||
|
<div>Min<br /><strong>{{ formatPrice(minPrice) }}</strong></div>
|
||||||
|
<div>Max<br /><strong>{{ formatPrice(maxPrice) }}</strong></div>
|
||||||
|
<div>Delta<br /><strong>{{ deltaLabel }}</strong></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visible: Boolean,
|
||||||
|
position: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({ top: 0, left: 0 }),
|
||||||
|
},
|
||||||
|
history: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
currentPrice: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
minPrice: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
maxPrice: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
delta: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedDelta = computed(() => {
|
||||||
|
const value = Number(props.delta ?? 0);
|
||||||
|
if (!Number.isFinite(value) || value === 0) {
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
const arrow = value > 0 ? "▲" : "▼";
|
||||||
|
return `${arrow} ${Math.abs(value).toFixed(1)}%`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const polyPoints = computed(() => {
|
||||||
|
if (!props.history.length) {
|
||||||
|
return "0,30 30,20 60,15 90,25 120,20";
|
||||||
|
}
|
||||||
|
const max = Math.max(...props.history);
|
||||||
|
const min = Math.min(...props.history);
|
||||||
|
const range = max - min || 1;
|
||||||
|
return props.history
|
||||||
|
.map((value, index) => {
|
||||||
|
const x = (index / (props.history.length - 1 || 1)) * 120;
|
||||||
|
const y = 40 - ((value - min) / range) * 40;
|
||||||
|
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
|
});
|
||||||
|
|
||||||
|
const svgPoints = computed(() => {
|
||||||
|
if (!props.history.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const max = Math.max(...props.history);
|
||||||
|
const min = Math.min(...props.history);
|
||||||
|
const range = max - min || 1;
|
||||||
|
return props.history.map((value, index) => {
|
||||||
|
const x = (index / (props.history.length - 1 || 1)) * 120;
|
||||||
|
const y = 40 - ((value - min) / range) * 40;
|
||||||
|
return { cx: x.toFixed(1), cy: y.toFixed(1) };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: "mouseenter"): void;
|
||||||
|
(event: "mouseleave"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const popupStyle = computed(() => ({
|
||||||
|
position: "fixed",
|
||||||
|
top: `${props.position.top}px`,
|
||||||
|
left: `${props.position.left}px`,
|
||||||
|
width: "280px",
|
||||||
|
zIndex: 50,
|
||||||
|
}));
|
||||||
|
const deltaLabel = formattedDelta;
|
||||||
|
|
||||||
|
const formatPrice = (value: number) => {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return "n/a";
|
||||||
|
}
|
||||||
|
return `${value.toFixed(2)} €`;
|
||||||
|
};
|
||||||
|
|
||||||
|
function keepOpen() {
|
||||||
|
emit("mouseenter");
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit("mouseleave");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -4,6 +4,10 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light dark;
|
color-scheme: light dark;
|
||||||
|
--pw-store-icon: 40px;
|
||||||
|
--pw-card-height-factor: 1;
|
||||||
|
--pw-card-mobile-height-factor: 1;
|
||||||
|
--pw-card-media-height: 160px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-root {
|
.app-root {
|
||||||
@@ -134,7 +138,264 @@
|
|||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
box-shadow: 0 10px 24px var(--shadow);
|
box-shadow: 0 16px 32px var(--shadow);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: calc(470px * var(--pw-card-height-factor, 1));
|
||||||
|
padding: 24px;
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-thumbnail {
|
||||||
|
width: 100%;
|
||||||
|
height: var(--pw-card-media-height, 160px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-media-image {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-media-contain {
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-media-cover {
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-price-history {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-price-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-panel {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-period-label {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-main {
|
||||||
|
font-size: clamp(24px, 2.2vw, 32px);
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-msrp {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: right;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-discount {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-line {
|
||||||
|
margin-top: 6px;
|
||||||
|
text-transform: none;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-summary {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.history-price-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-trend {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-pill {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-delta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-update {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: rgba(235, 219, 178, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-media {
|
||||||
|
width: 100%;
|
||||||
|
height: var(--pw-card-media-height, 160px);
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: var(--surface-2);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-media-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-identity {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-icon {
|
||||||
|
width: var(--pw-store-icon);
|
||||||
|
height: var(--pw-store-icon);
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: var(--surface-2);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-icon img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-identity-text {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip {
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-hover {
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-toolbar {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 12px;
|
||||||
|
right: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-toolbar .primary-action {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(4px);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group:hover .secondary-actions,
|
||||||
|
.group:focus-within .secondary-actions {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.card-hover:hover,
|
||||||
|
.card-hover:focus-within {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 18px 32px var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-delta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill[data-placeholder] {
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill.pill {
|
||||||
|
text-transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-accent {
|
.card-accent {
|
||||||
@@ -160,6 +421,178 @@
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-dialog {
|
||||||
|
max-height: 90vh;
|
||||||
|
border-radius: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-title {
|
||||||
|
font-weight: 600;
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 22ch;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-content-area {
|
||||||
|
padding: 24px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-columns {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card {
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.25);
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-summary-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-summary-image img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-tab-button {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-tab-button:hover {
|
||||||
|
border-color: rgba(255, 255, 255, 0.16);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-tab-button.active {
|
||||||
|
border-color: rgba(255, 255, 255, 0.35);
|
||||||
|
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-tab-panel {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-text {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-empty {
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-history-periods {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-period-button {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-period-button.selected {
|
||||||
|
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.25);
|
||||||
|
border-color: rgba(255, 255, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-history-summary .section-title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-price-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-price-value {
|
||||||
|
font-size: clamp(28px, 2.8vw, 34px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-price-updated {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-specs span {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card.edit-card .actions-section {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions .icon-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-product-btn {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
box-shadow: 0 16px 32px var(--shadow);
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-product-btn:hover {
|
||||||
|
transform: translateY(-3px) scale(1.02);
|
||||||
|
box-shadow: 0 20px 36px var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions .icon-btn .fa-solid {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
.input {
|
.input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
@@ -229,6 +662,96 @@
|
|||||||
color: #1b1b1b;
|
color: #1b1b1b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logs-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: min(80vh, 560px);
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: calc(80vh - 200px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-toolbar {
|
||||||
|
margin-top: auto;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrape-log-bar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1.5rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: min(80%, 900px);
|
||||||
|
max-height: 140px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 24px 40px rgba(0, 0, 0, 0.45);
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
z-index: 60;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrape-log-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrape-log-time {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrape-log-icon {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrape-log-text {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrape-log-enter-active,
|
||||||
|
.scrape-log-leave-active {
|
||||||
|
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrape-log-enter-from,
|
||||||
|
.scrape-log-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-section .price-value {
|
||||||
|
font-size: 2.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-section .link {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-section .icon-btn {
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
.app-root.layout-compact .sidebar,
|
.app-root.layout-compact .sidebar,
|
||||||
.app-root.layout-compact .detail-panel {
|
.app-root.layout-compact .detail-panel {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -254,8 +777,8 @@
|
|||||||
|
|
||||||
.product-grid {
|
.product-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 16px;
|
gap: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
@@ -278,4 +801,97 @@
|
|||||||
.product-grid {
|
.product-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
.card {
|
||||||
|
min-height: calc(470px * var(--pw-card-mobile-height-factor, 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.view-toggle-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-dominant {
|
||||||
|
margin-top: -4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-dominant .price-value {
|
||||||
|
font-size: clamp(24px, 3vw, 32px);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-dominant .price-value {
|
||||||
|
font-size: clamp(20px, 2.4vw, 26px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-sparkline {
|
||||||
|
margin-top: 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 6px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sparkline-polyline {
|
||||||
|
stroke: var(--text);
|
||||||
|
stroke-width: 1.3;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-line-chart__point--last {
|
||||||
|
r: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-placeholder {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
text-align: center;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
.mini-line-chart-panel {
|
||||||
|
height: 160px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
margin-top: 12px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-line-chart-wrapper {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-line-chart-wrapper svg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-history-popup {
|
||||||
|
width: 280px;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 14px 28px rgba(0, 0, 0, 0.35);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 120px;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-history-popup .sparkline {
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-history-popup strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-history-chart {
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.35);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-history-chart .sparkline {
|
||||||
|
stroke: var(--accent);
|
||||||
|
stroke-width: 1.6;
|
||||||
}
|
}
|
||||||
|
|||||||
38
webui/src/utils/computeFloatingPosition.ts
Normal file
38
webui/src/utils/computeFloatingPosition.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
export interface FloatingPositionParams {
|
||||||
|
rect: DOMRect;
|
||||||
|
popupWidth: number;
|
||||||
|
popupHeight: number;
|
||||||
|
viewportWidth: number;
|
||||||
|
viewportHeight: number;
|
||||||
|
margin?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FloatingPosition {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const computeFloatingPosition = ({
|
||||||
|
rect,
|
||||||
|
popupWidth,
|
||||||
|
popupHeight,
|
||||||
|
viewportWidth,
|
||||||
|
viewportHeight,
|
||||||
|
margin = 8,
|
||||||
|
}: FloatingPositionParams): FloatingPosition => {
|
||||||
|
const spaceBelow = viewportHeight - rect.bottom;
|
||||||
|
const spaceAbove = rect.top;
|
||||||
|
const verticalGap = margin;
|
||||||
|
let top = rect.bottom + verticalGap;
|
||||||
|
if (spaceBelow < popupHeight + verticalGap && spaceAbove >= popupHeight + verticalGap) {
|
||||||
|
top = rect.top - popupHeight - verticalGap;
|
||||||
|
}
|
||||||
|
if (spaceBelow < popupHeight + verticalGap && spaceAbove < popupHeight + verticalGap) {
|
||||||
|
top = Math.max(margin, viewportHeight - popupHeight - margin);
|
||||||
|
}
|
||||||
|
const clampedLeft = Math.min(
|
||||||
|
Math.max(margin, rect.left),
|
||||||
|
Math.max(margin, viewportWidth - popupWidth - margin)
|
||||||
|
);
|
||||||
|
return { top, left: clampedLeft };
|
||||||
|
};
|
||||||
26
webui/src/utils/storeLogos.ts
Normal file
26
webui/src/utils/storeLogos.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import amazonLogo from "@/assets/stores/amazon.svg";
|
||||||
|
import cdiscountLogo from "@/assets/stores/cdiscount.svg";
|
||||||
|
import aliexpressLogo from "@/assets/stores/aliexpress.svg";
|
||||||
|
import backmarketLogo from "@/assets/stores/backmarket.svg";
|
||||||
|
|
||||||
|
const LOGOS: Record<string, string> = {
|
||||||
|
amazon: amazonLogo,
|
||||||
|
cdiscount: cdiscountLogo,
|
||||||
|
aliexpress: aliexpressLogo,
|
||||||
|
backmarket: backmarketLogo,
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalize = (value: string | undefined): string => {
|
||||||
|
if (!value) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStoreLogo = (storeName: string | undefined): string | null => {
|
||||||
|
const key = normalize(storeName);
|
||||||
|
return LOGOS[key] || null;
|
||||||
|
};
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import vue from "@vitejs/plugin-vue";
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
import { fileURLToPath, URL } from "node:url";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user