Compare commits

..

3 Commits

Author SHA1 Message Date
Gilles Soulier
1f7f7da0c3 claude 2026-01-17 14:48:14 +01:00
Gilles Soulier
152c2724fc feat: improve SPA scraping and increase test coverage
- Add SPA support for Playwright with wait_for_network_idle and extra_wait_ms
- Add BaseStore.get_spa_config() and requires_playwright() methods
- Implement AliExpress SPA config with JSON price extraction patterns
- Fix Amazon price parsing to prioritize whole+fraction combination
- Fix AliExpress regex patterns (remove double backslashes)
- Add CLI tests: detect, doctor, fetch, parse, run commands
- Add API tests: auth, logs, products, scraping_logs, webhooks

Tests: 417 passed, 85% coverage

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 14:46:55 +01:00
Gilles Soulier
cf7c415e22 before claude 2026-01-17 13:40:26 +01:00
74 changed files with 4726 additions and 243 deletions

7
.claude/settings.json Normal file
View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(sort:*)"
]
}
}

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

BIN
pricewatch/app/core/__pycache__/io.cpython-313.pyc Executable file → Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -45,6 +45,8 @@ def fetch_playwright(
timeout_ms: int = 60000,
save_screenshot: bool = False,
wait_for_selector: Optional[str] = None,
wait_for_network_idle: bool = False,
extra_wait_ms: int = 0,
) -> PlaywrightFetchResult:
"""
Récupère une page avec Playwright.
@@ -55,6 +57,8 @@ def fetch_playwright(
timeout_ms: Timeout en millisecondes
save_screenshot: Prendre un screenshot
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:
PlaywrightFetchResult avec HTML, screenshot (optionnel), ou erreur
@@ -65,6 +69,8 @@ def fetch_playwright(
- Headful disponible pour debug visuel
- Screenshot optionnel pour diagnostiquer les échecs
- 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():
logger.error("URL vide fournie")
@@ -101,7 +107,8 @@ def fetch_playwright(
# Naviguer vers la page
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:
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}"
)
# 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
html = page.content()

BIN
pricewatch/app/stores/__pycache__/base.cpython-313.pyc Executable file → Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -29,13 +29,39 @@ logger = get_logger("stores.aliexpress")
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):
"""Initialise le store AliExpress avec ses sélecteurs."""
selectors_path = Path(__file__).parent / "selectors.yml"
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:
"""
Détecte si l'URL est AliExpress.
@@ -206,28 +232,71 @@ class AliexpressStore(BaseStore):
Extrait 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 €")
match = re.search(r"([0-9][0-9\\s.,\\u00a0\\u202f\\u2009]*)\\s*€", html)
# Priorité 1: Extraire depuis JSON embarqué (skuActivityAmount, formattedActivityPrice)
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:
price = parse_price_text(match.group(1))
if price is not None:
if price is not None and price > 0:
return price
# Pattern 2: € avant prix (ex: "€ 136.69")
match = re.search(r"\\s*([0-9][0-9\\s.,\\u00a0\\u202f\\u2009]*)", html)
# Priorité 4: € avant prix (ex: "€136.69" ou "€ 136.69")
match = re.search(r'\s*(\d+[,.\s\u00a0\u202f\u2009]*\d*)', html)
if match:
price = parse_price_text(match.group(1))
if price is not None:
if price is not None and price > 0:
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")
if og_price:
price_str = og_price.get("content", "")
price = parse_price_text(price_str)
if price is not None:
if price is not None and price > 0:
return price
debug.errors.append("Prix non trouvé")
@@ -235,7 +304,7 @@ class AliexpressStore(BaseStore):
def _extract_msrp(self, html: str, debug: DebugInfo) -> Optional[float]:
"""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:
price = parse_price_text(match.group(1))
if price is not None:

View File

@@ -215,6 +215,19 @@ class AmazonStore(BaseStore):
def _extract_price(self, soup: BeautifulSoup, debug: DebugInfo) -> Optional[float]:
"""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", [])
if isinstance(selectors, str):
selectors = [selectors]
@@ -227,16 +240,6 @@ class AmazonStore(BaseStore):
if price is not None:
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é")
return None

Binary file not shown.

View File

@@ -152,5 +152,32 @@ class BaseStore(ABC):
"""
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:
return f"<{self.__class__.__name__} id={self.store_id}>"

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 @@
<html><body>content</body></html>

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

View 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

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

View 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

View 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

View 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

42
tests/cli/test_detect.py Normal file
View 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
View 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
View 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
View 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

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

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