- Rewrite PSN scraper to use /api/units-list endpoint (single API call, no HTML parsing) - Fix CityHome scraper: GPS from multiple URL patterns, address from table cells, no 404 retries - Color map markers by price/m² instead of disposition (blue→green→orange→red scale) - Add persistent rating system (favorite/reject) with Flask ratings server and localStorage fallback - Rejected markers show original color at reduced opacity with 🚫 SVG overlay - Favorite markers shown as ⭐ star icons with gold pulse animation - Add "new today" marker logic (scraped_at == today) with larger pulsing green outline - Add filter panel with floor, price, hide-rejected controls and ☰/✕ toggle buttons - Add generate_status.py for scraper run statistics and status.html dashboard - Add scraped_at field to all scrapers for freshness tracking - Update run_all.sh with log capture and status generation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
205 lines
7.3 KiB
HTML
205 lines
7.3 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="cs">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Scraper status</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body {
|
|
font-family: system-ui, -apple-system, sans-serif;
|
|
background: #f5f5f5; color: #333;
|
|
padding: 24px; max-width: 640px; margin: 0 auto;
|
|
}
|
|
h1 { font-size: 22px; margin-bottom: 4px; }
|
|
.subtitle { color: #888; font-size: 13px; margin-bottom: 24px; }
|
|
.card {
|
|
background: white; border-radius: 12px; padding: 20px;
|
|
box-shadow: 0 1px 4px rgba(0,0,0,0.08); margin-bottom: 16px;
|
|
}
|
|
.card h2 { font-size: 15px; margin-bottom: 12px; color: #555; }
|
|
.timestamp {
|
|
font-size: 28px; font-weight: 700; color: #1976D2;
|
|
}
|
|
.timestamp-ago { font-size: 13px; color: #999; margin-top: 2px; }
|
|
|
|
/* Source table */
|
|
.source-table { width: 100%; border-collapse: collapse; }
|
|
.source-table td { padding: 8px 0; border-bottom: 1px solid #f0f0f0; font-size: 14px; }
|
|
.source-table tr:last-child td { border-bottom: none; }
|
|
.source-table .name { font-weight: 600; }
|
|
.source-table .count { text-align: right; font-variant-numeric: tabular-nums; }
|
|
.source-table .rejected { text-align: right; color: #999; font-size: 12px; }
|
|
.badge {
|
|
display: inline-block; padding: 2px 8px; border-radius: 4px;
|
|
font-size: 11px; font-weight: 600; color: white;
|
|
}
|
|
.badge-ok { background: #4CAF50; }
|
|
.badge-err { background: #F44336; }
|
|
.badge-skip { background: #FF9800; }
|
|
|
|
/* Summary bar */
|
|
.summary-row {
|
|
display: flex; justify-content: space-between; align-items: center;
|
|
padding: 10px 0; border-bottom: 1px solid #f0f0f0;
|
|
}
|
|
.summary-row:last-child { border-bottom: none; }
|
|
.summary-label { font-size: 13px; color: #666; }
|
|
.summary-value { font-size: 18px; font-weight: 700; }
|
|
|
|
/* Source bar chart */
|
|
.bar-row { display: flex; align-items: center; gap: 8px; margin: 4px 0; }
|
|
.bar-label { width: 90px; font-size: 12px; text-align: right; color: #666; }
|
|
.bar-track { flex: 1; height: 20px; background: #f0f0f0; border-radius: 4px; overflow: hidden; position: relative; }
|
|
.bar-fill { height: 100%; border-radius: 4px; transition: width 0.5s ease; }
|
|
.bar-count { font-size: 12px; width: 36px; font-variant-numeric: tabular-nums; }
|
|
|
|
/* Loader */
|
|
.loader-wrap {
|
|
display: flex; flex-direction: column; align-items: center;
|
|
justify-content: center; padding: 60px 0;
|
|
}
|
|
.spinner {
|
|
width: 40px; height: 40px; border: 4px solid #e0e0e0;
|
|
border-top-color: #1976D2; border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
.loader-text { margin-top: 16px; color: #999; font-size: 14px; }
|
|
|
|
.error-msg { color: #F44336; padding: 40px 0; text-align: center; }
|
|
.link-row { text-align: center; margin-top: 8px; }
|
|
.link-row a { color: #1976D2; text-decoration: none; font-size: 14px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<h1>Scraper status</h1>
|
|
<div class="subtitle">maru-hleda-byt</div>
|
|
|
|
<div id="content">
|
|
<div class="loader-wrap">
|
|
<div class="spinner"></div>
|
|
<div class="loader-text">Nacitam status...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="link-row"><a href="mapa_bytu.html">Otevrit mapu</a></div>
|
|
|
|
<script>
|
|
var COLORS = {
|
|
sreality: '#1976D2',
|
|
realingo: '#7B1FA2',
|
|
bezrealitky: '#E65100',
|
|
idnes: '#C62828',
|
|
psn: '#2E7D32',
|
|
cityhome: '#00838F',
|
|
};
|
|
|
|
function timeAgo(dateStr) {
|
|
var d = new Date(dateStr);
|
|
var now = new Date();
|
|
var diff = Math.floor((now - d) / 1000);
|
|
if (diff < 60) return 'prave ted';
|
|
if (diff < 3600) return Math.floor(diff / 60) + ' min zpet';
|
|
if (diff < 86400) return Math.floor(diff / 3600) + ' hod zpet';
|
|
return Math.floor(diff / 86400) + ' dni zpet';
|
|
}
|
|
|
|
function formatDate(dateStr) {
|
|
var d = new Date(dateStr);
|
|
var day = d.getDate();
|
|
var months = ['ledna','unora','brezna','dubna','kvetna','cervna',
|
|
'cervence','srpna','zari','rijna','listopadu','prosince'];
|
|
var hh = String(d.getHours()).padStart(2, '0');
|
|
var mm = String(d.getMinutes()).padStart(2, '0');
|
|
return day + '. ' + months[d.getMonth()] + ' ' + d.getFullYear() + ', ' + hh + ':' + mm;
|
|
}
|
|
|
|
function render(data) {
|
|
// Check if scrape is currently running
|
|
if (data.status === 'running') {
|
|
document.getElementById('content').innerHTML =
|
|
'<div class="loader-wrap">' +
|
|
'<div class="spinner"></div>' +
|
|
'<div class="loader-text">Scraper prave bezi...</div>' +
|
|
'</div>';
|
|
setTimeout(loadStatus, 30000);
|
|
return;
|
|
}
|
|
|
|
var sources = data.sources || [];
|
|
var totalOk = 0, totalRej = 0;
|
|
var maxCount = 0;
|
|
sources.forEach(function(s) {
|
|
totalOk += s.accepted || 0;
|
|
totalRej += s.rejected || 0;
|
|
if (s.accepted > maxCount) maxCount = s.accepted;
|
|
});
|
|
|
|
var html = '';
|
|
|
|
// Timestamp card
|
|
html += '<div class="card">';
|
|
html += '<h2>Posledni scrape</h2>';
|
|
html += '<div class="timestamp">' + formatDate(data.timestamp) + '</div>';
|
|
html += '<div class="timestamp-ago">' + timeAgo(data.timestamp) + '</div>';
|
|
if (data.duration_sec) {
|
|
html += '<div class="timestamp-ago">Trvani: ' + Math.round(data.duration_sec) + 's</div>';
|
|
}
|
|
html += '</div>';
|
|
|
|
// Summary card
|
|
html += '<div class="card">';
|
|
html += '<h2>Souhrn</h2>';
|
|
html += '<div class="summary-row"><span class="summary-label">Vyhovujicich bytu</span><span class="summary-value" style="color:#4CAF50">' + totalOk + '</span></div>';
|
|
html += '<div class="summary-row"><span class="summary-label">Vyloucenych</span><span class="summary-value" style="color:#999">' + totalRej + '</span></div>';
|
|
if (data.deduplicated !== undefined) {
|
|
html += '<div class="summary-row"><span class="summary-label">Po deduplikaci (v mape)</span><span class="summary-value" style="color:#1976D2">' + data.deduplicated + '</span></div>';
|
|
}
|
|
html += '</div>';
|
|
|
|
// Sources card
|
|
html += '<div class="card">';
|
|
html += '<h2>Zdroje</h2>';
|
|
sources.forEach(function(s) {
|
|
var color = COLORS[s.name.toLowerCase()] || '#999';
|
|
var pct = maxCount > 0 ? Math.round((s.accepted / maxCount) * 100) : 0;
|
|
var badge = s.error
|
|
? '<span class="badge badge-err">chyba</span>'
|
|
: (s.accepted === 0 ? '<span class="badge badge-skip">0</span>' : '<span class="badge badge-ok">OK</span>');
|
|
|
|
html += '<div style="margin-bottom:12px;">';
|
|
html += '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;">';
|
|
html += '<span style="font-weight:600;font-size:14px;">' + s.name + ' ' + badge + '</span>';
|
|
html += '<span style="font-size:12px;color:#999;">' + (s.rejected || 0) + ' vyloucenych</span>';
|
|
html += '</div>';
|
|
html += '<div class="bar-row">';
|
|
html += '<div class="bar-track"><div class="bar-fill" style="width:' + pct + '%;background:' + color + ';"></div></div>';
|
|
html += '<span class="bar-count">' + (s.accepted || 0) + '</span>';
|
|
html += '</div>';
|
|
html += '</div>';
|
|
});
|
|
html += '</div>';
|
|
|
|
document.getElementById('content').innerHTML = html;
|
|
}
|
|
|
|
function loadStatus() {
|
|
fetch('status.json?t=' + Date.now())
|
|
.then(function(r) {
|
|
if (!r.ok) throw new Error(r.status);
|
|
return r.json();
|
|
})
|
|
.then(render)
|
|
.catch(function(err) {
|
|
document.getElementById('content').innerHTML =
|
|
'<div class="error-msg">Status zatim neni k dispozici.<br><small>(' + err.message + ')</small></div>';
|
|
});
|
|
}
|
|
|
|
loadStatus();
|
|
</script>
|
|
</body>
|
|
</html>
|