before claude

This commit is contained in:
Gilles Soulier
2026-01-17 13:40:26 +01:00
parent d0b73b9319
commit cf7c415e22
35 changed files with 3411 additions and 221 deletions

BIN
.coverage

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

BIN
Image collée (5).png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

15
analytics-ui/Dockerfile Normal file
View 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
View 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)

View File

@@ -0,0 +1,3 @@
Flask==3.0.0
psycopg2-binary==2.9.11
redis==5.0.0

View File

@@ -40,9 +40,41 @@ services:
- "3000:80"
environment:
TZ: Europe/Paris
VITE_API_TOKEN: ${API_TOKEN:-}
env_file:
- .env
depends_on:
- 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:
pricewatch_pgdata:
pricewatch_redisdata:

View File

@@ -0,0 +1,50 @@
## Objectif
Améliorer la clarté et la lisibilité de linterface (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 1820px, 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 dacceptation
- 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
View File

@@ -0,0 +1,26 @@
## Fonctionnement général de PriceWatch
Lorsquun utilisateur colle une URL dans la web UI et déclenche lajout/déclenchement dun 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 lURL + 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)**
* Lendpoint `/scrape/preview` reçoit lURL, 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 dimages (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 derreur (404, 401, scraping bloqué), lutilisateur voit directement le message retourné.
3. **Confirmation / persist**
* Quand lutilisateur clique sur "Enregistrer", la web UI déclenche `POST /scrape/commit` avec lobjet snapshot.
* Le backend réinsère les données dans la base (`pricewatch/app/core/io.py`) et lAPI `/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 dune 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.

BIN
image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -21,31 +21,32 @@ from sqlalchemy import and_, desc, func
from sqlalchemy.orm import Session
from pricewatch.app.api.schemas import (
BackendLogEntry,
EnqueueRequest,
EnqueueResponse,
HealthStatus,
PriceHistoryOut,
PriceHistoryCreate,
PriceHistoryOut,
PriceHistoryUpdate,
ProductOut,
ProductCreate,
ProductHistoryPoint,
ProductOut,
ProductUpdate,
ScheduleRequest,
ScheduleResponse,
ScrapingLogOut,
ScrapingLogCreate,
ScrapingLogUpdate,
ScrapePreviewRequest,
ScrapePreviewResponse,
ScrapeCommitRequest,
ScrapeCommitResponse,
VersionResponse,
BackendLogEntry,
ScrapePreviewRequest,
ScrapePreviewResponse,
ScrapingLogCreate,
ScrapingLogOut,
ScrapingLogUpdate,
UvicornLogEntry,
WebhookOut,
VersionResponse,
WebhookCreate,
WebhookUpdate,
WebhookOut,
WebhookTestResponse,
WebhookUpdate,
)
from pricewatch.app.core.config import get_config
from pricewatch.app.core.logging import get_logger
@@ -794,6 +795,9 @@ def _read_uvicorn_lines(limit: int = 200) -> list[str]:
return []
PRODUCT_HISTORY_LIMIT = 12
def _product_to_out(session: Session, product: Product) -> ProductOut:
"""Helper pour mapper Product + dernier prix."""
latest = (
@@ -810,6 +814,18 @@ def _product_to_out(session: Session, product: Product) -> ProductOut:
discount_amount = float(product.msrp) - float(latest.price)
if product.msrp > 0:
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(
id=product.id,
source=product.source,
@@ -832,6 +848,7 @@ def _product_to_out(session: Session, product: Product) -> ProductOut:
specs=specs,
discount_amount=discount_amount,
discount_percent=discount_percent,
history=history_points,
)

View File

@@ -13,6 +13,11 @@ class HealthStatus(BaseModel):
redis: bool
class ProductHistoryPoint(BaseModel):
price: float
fetched_at: datetime
class ProductOut(BaseModel):
id: int
source: str
@@ -33,6 +38,7 @@ class ProductOut(BaseModel):
specs: dict[str, str] = {}
discount_amount: Optional[float] = None
discount_percent: Optional[float] = None
history: list[ProductHistoryPoint] = Field(default_factory=list)
class ProductCreate(BaseModel):

Binary file not shown.

View File

@@ -112,7 +112,7 @@ class CdiscountStore(BaseStore):
currency = self._extract_currency(soup, debug_info)
stock_status = self._extract_stock(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)
description = self._extract_description(soup, debug_info)
msrp = self._extract_msrp(soup, debug_info)
@@ -180,7 +180,7 @@ class CdiscountStore(BaseStore):
return None
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", [])
if isinstance(selectors, str):
selectors = [selectors]
@@ -188,16 +188,33 @@ class CdiscountStore(BaseStore):
for selector in selectors:
elements = soup.select(selector)
for element in elements:
# Attribut content (schema.org) ou texte
price_text = element.get("content") or element.get_text(strip=True)
price = parse_price_text(price_text)
if price is not None:
return price
price = self._extract_price_from_json_ld(soup)
if price is not None:
return price
debug.errors.append("Prix non trouvé")
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]:
"""Extrait le prix conseille."""
selectors = [
@@ -205,6 +222,8 @@ class CdiscountStore(BaseStore):
".price__old",
".c-price__strike",
".price-strike",
"div[data-e2e='strikedPrice']",
"div.SecondaryPrice-price",
]
for selector in selectors:
element = soup.select_one(selector)
@@ -212,6 +231,19 @@ class CdiscountStore(BaseStore):
price = parse_price_text(element.get_text(strip=True))
if price is not None:
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
def _extract_currency(self, soup: BeautifulSoup, debug: DebugInfo) -> Optional[str]:
@@ -288,7 +320,7 @@ class CdiscountStore(BaseStore):
return list(dict.fromkeys(images)) # Préserver lordre
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."""
selectors = self.get_selector("category", [])
if isinstance(selectors, str):
@@ -310,6 +342,54 @@ class CdiscountStore(BaseStore):
if parts:
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
def _extract_json_ld_entries(self, soup: BeautifulSoup) -> list[dict]:

View File

@@ -17,6 +17,18 @@ def parse_price_text(text: str) -> Optional[float]:
if not text:
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)
cleaned = text.replace("\u00a0", " ").replace("\u202f", " ").replace("\u2009", " ")
cleaned = "".join(ch for ch in cleaned if ch.isdigit() or ch in ".,")

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

View File

@@ -171,7 +171,25 @@ class TestCdiscountRealFixtures:
assert isinstance(snapshot.price, float)
assert snapshot.price > 0
# 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):
"""Parse fixture a128902 - le prix doit être un float valide."""

View File

@@ -27,3 +27,7 @@ def test_parse_price_without_decimal():
def test_parse_price_with_currency():
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

File diff suppressed because it is too large Load Diff

View 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

View 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

View 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

View 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

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

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

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

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

View File

@@ -4,6 +4,10 @@
:root {
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 {
@@ -134,7 +138,264 @@
background: var(--surface);
border-radius: var(--radius);
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 {
@@ -160,6 +421,178 @@
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 {
width: 100%;
background: var(--surface-2);
@@ -229,6 +662,96 @@
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 .detail-panel {
display: none;
@@ -254,8 +777,8 @@
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 16px;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 32px;
}
@media (max-width: 1024px) {
@@ -278,4 +801,97 @@
.product-grid {
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;
}

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

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

View File

@@ -1,8 +1,14 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { fileURLToPath, URL } from "node:url";
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
server: {
port: 3000,
},