before claude
This commit is contained in:
@@ -76,6 +76,81 @@ def _serialize_decimal(value):
|
||||
return value
|
||||
|
||||
|
||||
def fetch_product_history(product_id: int) -> Tuple[List[Dict[str, Any]], Optional[str]]:
|
||||
"""Récupère l'historique complet des scraps pour un produit."""
|
||||
rows: List[Dict[str, Any]] = []
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
ph.id,
|
||||
ph.price,
|
||||
ph.shipping_cost,
|
||||
ph.stock_status,
|
||||
ph.fetch_method,
|
||||
ph.fetch_status,
|
||||
ph.fetched_at
|
||||
FROM price_history ph
|
||||
WHERE ph.product_id = %s
|
||||
ORDER BY ph.fetched_at DESC
|
||||
""",
|
||||
(product_id,),
|
||||
)
|
||||
fetched = cur.fetchall()
|
||||
for item in fetched:
|
||||
serialized = {key: _serialize_decimal(value) for key, value in item.items()}
|
||||
if serialized.get("fetched_at"):
|
||||
serialized["fetched_at"] = serialized["fetched_at"].strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
rows.append(serialized)
|
||||
return rows, None
|
||||
except Exception as exc:
|
||||
return rows, str(exc)
|
||||
|
||||
|
||||
def fetch_all_price_history(limit: int = 500) -> Tuple[List[Dict[str, Any]], Optional[str]]:
|
||||
"""Récupère toutes les entrées de price_history avec infos produit."""
|
||||
rows: List[Dict[str, Any]] = []
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
ph.id,
|
||||
ph.product_id,
|
||||
p.source,
|
||||
p.reference,
|
||||
p.title,
|
||||
ph.price,
|
||||
ph.shipping_cost,
|
||||
ph.stock_status,
|
||||
ph.fetch_method,
|
||||
ph.fetch_status,
|
||||
ph.fetched_at
|
||||
FROM price_history ph
|
||||
LEFT JOIN products p ON p.id = ph.product_id
|
||||
ORDER BY ph.fetched_at DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
fetched = cur.fetchall()
|
||||
for item in fetched:
|
||||
serialized = {key: _serialize_decimal(value) for key, value in item.items()}
|
||||
if serialized.get("fetched_at"):
|
||||
serialized["fetched_at"] = serialized["fetched_at"].strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
rows.append(serialized)
|
||||
return rows, None
|
||||
except Exception as exc:
|
||||
return rows, str(exc)
|
||||
|
||||
|
||||
def fetch_products_list(limit: int = 200) -> Tuple[List[Dict[str, Any]], Optional[str]]:
|
||||
rows: List[Dict[str, Any]] = []
|
||||
try:
|
||||
@@ -260,6 +335,68 @@ TEMPLATE = """
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Historique complet des scraps</h2>
|
||||
<div class="browser-panel">
|
||||
<div class="browser-controls">
|
||||
<button id="load-history">Charger l'historique du produit sélectionné</button>
|
||||
<span class="muted" id="history-message"></span>
|
||||
</div>
|
||||
<div class="history-table-container" style="max-height: 400px; overflow-y: auto; margin-top: 12px;">
|
||||
<table id="history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Prix</th>
|
||||
<th>Frais port</th>
|
||||
<th>Stock</th>
|
||||
<th>Méthode</th>
|
||||
<th>Statut</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="history-body">
|
||||
<tr><td colspan="6" class="muted">Sélectionnez un produit puis cliquez sur "Charger l'historique"</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Parcourir la table price_history</h2>
|
||||
<div class="browser-panel">
|
||||
<div class="browser-controls">
|
||||
<button id="load-price-history">Charger price_history</button>
|
||||
<button id="ph-prev" disabled>Précédent</button>
|
||||
<button id="ph-next" disabled>Suivant</button>
|
||||
<strong class="browser-indicator" id="ph-indicator">0 / 0</strong>
|
||||
<span class="muted" id="ph-message"></span>
|
||||
</div>
|
||||
<dl class="browser-display" id="ph-details">
|
||||
<dt>ID</dt>
|
||||
<dd id="ph-id">-</dd>
|
||||
<dt>Product ID</dt>
|
||||
<dd id="ph-product-id">-</dd>
|
||||
<dt>Store</dt>
|
||||
<dd id="ph-source">-</dd>
|
||||
<dt>Référence</dt>
|
||||
<dd id="ph-reference">-</dd>
|
||||
<dt>Titre produit</dt>
|
||||
<dd id="ph-title">-</dd>
|
||||
<dt>Prix</dt>
|
||||
<dd id="ph-price">-</dd>
|
||||
<dt>Frais de port</dt>
|
||||
<dd id="ph-shipping">-</dd>
|
||||
<dt>Stock</dt>
|
||||
<dd id="ph-stock">-</dd>
|
||||
<dt>Méthode</dt>
|
||||
<dd id="ph-method">-</dd>
|
||||
<dt>Statut</dt>
|
||||
<dd id="ph-status">-</dd>
|
||||
<dt>Date scraping</dt>
|
||||
<dd id="ph-fetched-at">-</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
@@ -348,6 +485,177 @@ TEMPLATE = """
|
||||
renderProduct();
|
||||
}
|
||||
});
|
||||
|
||||
// Historique des scraps
|
||||
const loadHistoryBtn = document.getElementById("load-history");
|
||||
const historyMessage = document.getElementById("history-message");
|
||||
const historyBody = document.getElementById("history-body");
|
||||
|
||||
const setHistoryStatus = (text) => {
|
||||
historyMessage.textContent = text || "";
|
||||
};
|
||||
|
||||
const formatStock = (status) => {
|
||||
const stockMap = {
|
||||
"in_stock": "✓ En stock",
|
||||
"out_of_stock": "✗ Rupture",
|
||||
"limited": "⚠ Limité",
|
||||
"preorder": "⏳ Précommande",
|
||||
"unknown": "? Inconnu"
|
||||
};
|
||||
return stockMap[status] || status || "-";
|
||||
};
|
||||
|
||||
const formatMethod = (method) => {
|
||||
return method === "playwright" ? "🎭 Playwright" : "📡 HTTP";
|
||||
};
|
||||
|
||||
const formatStatus = (status) => {
|
||||
const statusMap = {
|
||||
"success": "✓ Succès",
|
||||
"partial": "⚠ Partiel",
|
||||
"failed": "✗ Échec"
|
||||
};
|
||||
return statusMap[status] || status || "-";
|
||||
};
|
||||
|
||||
const renderHistory = (history) => {
|
||||
if (!history.length) {
|
||||
historyBody.innerHTML = '<tr><td colspan="6" class="muted">Aucun historique disponible pour ce produit.</td></tr>';
|
||||
return;
|
||||
}
|
||||
historyBody.innerHTML = history.map(entry => `
|
||||
<tr>
|
||||
<td>${entry.fetched_at || "-"}</td>
|
||||
<td>${entry.price !== null ? entry.price + " €" : "-"}</td>
|
||||
<td>${entry.shipping_cost !== null ? entry.shipping_cost + " €" : "-"}</td>
|
||||
<td>${formatStock(entry.stock_status)}</td>
|
||||
<td>${formatMethod(entry.fetch_method)}</td>
|
||||
<td>${formatStatus(entry.fetch_status)}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
};
|
||||
|
||||
const fetchHistory = async () => {
|
||||
if (!products.length) {
|
||||
setHistoryStatus("Chargez d'abord les produits.");
|
||||
return;
|
||||
}
|
||||
const current = products[cursor];
|
||||
if (!current || !current.id) {
|
||||
setHistoryStatus("Aucun produit sélectionné.");
|
||||
return;
|
||||
}
|
||||
setHistoryStatus(`Chargement de l'historique pour le produit #${current.id}…`);
|
||||
try {
|
||||
const response = await fetch(`/product/${current.id}/history.json`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error("Réponse invalide");
|
||||
}
|
||||
setHistoryStatus(`${data.length} entrée(s) pour "${(current.title || "Sans titre").slice(0, 30)}…"`);
|
||||
renderHistory(data);
|
||||
} catch (err) {
|
||||
setHistoryStatus(`Erreur: ${err.message}`);
|
||||
historyBody.innerHTML = '<tr><td colspan="6" class="muted">Erreur lors du chargement.</td></tr>';
|
||||
}
|
||||
};
|
||||
|
||||
loadHistoryBtn.addEventListener("click", fetchHistory);
|
||||
|
||||
// Parcourir price_history
|
||||
const loadPhBtn = document.getElementById("load-price-history");
|
||||
const phPrevBtn = document.getElementById("ph-prev");
|
||||
const phNextBtn = document.getElementById("ph-next");
|
||||
const phIndicator = document.getElementById("ph-indicator");
|
||||
const phMessage = document.getElementById("ph-message");
|
||||
let priceHistoryData = [];
|
||||
let phCursor = 0;
|
||||
|
||||
const setPhStatus = (text) => {
|
||||
phMessage.textContent = text || "";
|
||||
};
|
||||
|
||||
const renderPriceHistory = () => {
|
||||
const els = {
|
||||
id: document.getElementById("ph-id"),
|
||||
productId: document.getElementById("ph-product-id"),
|
||||
source: document.getElementById("ph-source"),
|
||||
reference: document.getElementById("ph-reference"),
|
||||
title: document.getElementById("ph-title"),
|
||||
price: document.getElementById("ph-price"),
|
||||
shipping: document.getElementById("ph-shipping"),
|
||||
stock: document.getElementById("ph-stock"),
|
||||
method: document.getElementById("ph-method"),
|
||||
status: document.getElementById("ph-status"),
|
||||
fetchedAt: document.getElementById("ph-fetched-at"),
|
||||
};
|
||||
|
||||
if (!priceHistoryData.length) {
|
||||
phIndicator.textContent = "0 / 0";
|
||||
Object.values(els).forEach((el) => (el.textContent = "-"));
|
||||
phPrevBtn.disabled = true;
|
||||
phNextBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const current = priceHistoryData[phCursor];
|
||||
phIndicator.textContent = `${phCursor + 1} / ${priceHistoryData.length}`;
|
||||
|
||||
els.id.textContent = current.id || "-";
|
||||
els.productId.textContent = current.product_id || "-";
|
||||
els.source.textContent = current.source || "-";
|
||||
els.reference.textContent = current.reference || "-";
|
||||
els.title.textContent = current.title ? (current.title.length > 60 ? current.title.slice(0, 60) + "…" : current.title) : "-";
|
||||
els.price.textContent = current.price !== null ? current.price + " €" : "-";
|
||||
els.shipping.textContent = current.shipping_cost !== null ? current.shipping_cost + " €" : "-";
|
||||
els.stock.textContent = formatStock(current.stock_status);
|
||||
els.method.textContent = formatMethod(current.fetch_method);
|
||||
els.status.textContent = formatStatus(current.fetch_status);
|
||||
els.fetchedAt.textContent = current.fetched_at || "-";
|
||||
|
||||
phPrevBtn.disabled = phCursor === 0;
|
||||
phNextBtn.disabled = phCursor >= priceHistoryData.length - 1;
|
||||
};
|
||||
|
||||
const fetchPriceHistory = async () => {
|
||||
setPhStatus("Chargement…");
|
||||
try {
|
||||
const response = await fetch("/price_history.json");
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error("Réponse invalide");
|
||||
}
|
||||
priceHistoryData = data;
|
||||
phCursor = 0;
|
||||
setPhStatus(`Chargé ${priceHistoryData.length} entrée(s)`);
|
||||
renderPriceHistory();
|
||||
} catch (err) {
|
||||
setPhStatus(`Erreur: ${err.message}`);
|
||||
priceHistoryData = [];
|
||||
renderPriceHistory();
|
||||
}
|
||||
};
|
||||
|
||||
loadPhBtn.addEventListener("click", fetchPriceHistory);
|
||||
phPrevBtn.addEventListener("click", () => {
|
||||
if (phCursor > 0) {
|
||||
phCursor -= 1;
|
||||
renderPriceHistory();
|
||||
}
|
||||
});
|
||||
phNextBtn.addEventListener("click", () => {
|
||||
if (phCursor + 1 < priceHistoryData.length) {
|
||||
phCursor += 1;
|
||||
renderPriceHistory();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
@@ -377,5 +685,21 @@ def products_json():
|
||||
return jsonify(products)
|
||||
|
||||
|
||||
@app.route("/product/<int:product_id>/history.json")
|
||||
def product_history_json(product_id: int):
|
||||
history, error = fetch_product_history(product_id)
|
||||
if error:
|
||||
return jsonify({"error": error}), 500
|
||||
return jsonify(history)
|
||||
|
||||
|
||||
@app.route("/price_history.json")
|
||||
def all_price_history_json():
|
||||
history, error = fetch_all_price_history()
|
||||
if error:
|
||||
return jsonify({"error": error}), 500
|
||||
return jsonify(history)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=80)
|
||||
|
||||
Reference in New Issue
Block a user