Files
aspekter/coin-scout/public/index.html
s.zotov d34f04e922 Rename EMAILBRO → ASPEKTER, update Coin Scout, security fixes
- Rename: Docker containers, UI, nginx, User-Agent strings
- Coin Scout: sync from COIN_SCOUT project (latest version)
- Security: Pug injection protection (validatePugSafety)
- Security: concurrent render fix (unique temp files)
- Fix: disappearing IDs input when cleared
- Audit logging: all mutations, login/logout
- Users: createdBy/updatedBy on letters
- Local image storage option
2026-04-13 12:39:25 +05:00

1005 lines
70 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Coin Scout</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e1e4e8; }
a { color: #58a6ff; text-decoration: none; }
a:hover { text-decoration: underline; }
.header { background: #161b22; border-bottom: 1px solid #30363d; padding: 16px 24px; display: flex; align-items: center; gap: 16px; }
.header h1 { font-size: 20px; font-weight: 600; }
.header .stats { font-size: 13px; color: #8b949e; margin-left: auto; }
.toolbar { background: #161b22; border-bottom: 1px solid #30363d; padding: 12px 24px; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.toolbar label { font-size: 13px; color: #8b949e; }
.toolbar input, .toolbar select { background: #0d1117; border: 1px solid #30363d; color: #e1e4e8; padding: 6px 10px; border-radius: 6px; font-size: 13px; }
.toolbar input:focus, .toolbar select:focus { border-color: #58a6ff; outline: none; }
.toolbar input[type="number"] { width: 80px; }
.btn { background: #21262d; border: 1px solid #30363d; color: #e1e4e8; padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 13px; }
.btn:hover { background: #30363d; }
.btn-primary { background: #238636; border-color: #238636; }
.btn-primary:hover { background: #2ea043; }
.btn-scan { background: #1f6feb; border-color: #1f6feb; }
.btn-scan:hover { background: #388bfd; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.tabs { display: flex; gap: 0; border-bottom: 1px solid #30363d; background: #161b22; padding: 0 24px; }
.tab { padding: 10px 16px; font-size: 14px; color: #8b949e; cursor: pointer; border-bottom: 2px solid transparent; }
.tab:hover { color: #e1e4e8; }
.tab.active { color: #e1e4e8; border-bottom-color: #f78166; }
.content { padding: 16px 24px; }
.coin-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 12px; }
.coin-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 14px; display: flex; gap: 12px; transition: border-color 0.2s; }
.coin-card:hover { border-color: #58a6ff; }
.coin-card.top { border-color: #f0883e; }
.coin-img { width: 80px; height: 80px; border-radius: 6px; object-fit: cover; background: #0d1117; flex-shrink: 0; }
.coin-info { flex: 1; min-width: 0; }
.coin-name { font-size: 14px; font-weight: 500; margin-bottom: 4px; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.coin-meta { font-size: 12px; color: #8b949e; display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
.coin-meta .tag { background: #21262d; padding: 2px 6px; border-radius: 4px; }
.coin-meta .tag.silver { background: #1c3a5a; color: #79c0ff; }
.coin-meta .tag.gold { background: #3d2e00; color: #e3b341; }
.coin-meta .tag.grade-high { background: #1a4731; color: #56d364; }
.coin-meta .tag.grade-mid { background: #3d2e00; color: #e3b341; }
.coin-meta .tag.no-stock { background: #490202; color: #f85149; }
.coin-meta .tag.special { background: #3d1f00; color: #f0883e; font-weight: 600; }
.coin-price { font-size: 16px; font-weight: 600; color: #56d364; margin-top: 6px; }
.coin-price .old { font-size: 12px; color: #8b949e; text-decoration: line-through; margin-left: 6px; font-weight: 400; }
.coin-score { font-size: 11px; color: #f0883e; font-weight: 600; margin-top: 4px; }
.coin-feed { font-size: 11px; color: #8b949e; }
.scan-bar { background: #1c2128; border: 1px solid #30363d; border-radius: 8px; padding: 14px; margin-bottom: 16px; display: flex; align-items: center; gap: 12px; }
.scan-bar .status { font-size: 13px; }
.scan-bar .dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.dot.green { background: #56d364; }
.dot.yellow { background: #e3b341; animation: pulse 1s infinite; }
.dot.red { background: #f85149; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
.settings-panel { max-width: 500px; }
.settings-panel .field { margin-bottom: 14px; }
.settings-panel .field label { display: block; font-size: 13px; color: #8b949e; margin-bottom: 4px; }
.settings-panel .field input, .settings-panel .field select { width: 100%; }
.empty { text-align: center; padding: 60px; color: #8b949e; }
.empty p { margin-top: 8px; }
.loading { text-align: center; padding: 40px; color: #8b949e; }
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); z-index: 1000; display: flex; align-items: center; justify-content: center; padding: 20px; }
.modal { background: #161b22; border: 1px solid #30363d; border-radius: 12px; max-width: 650px; width: 100%; max-height: 85vh; overflow-y: auto; padding: 24px; position: relative; }
.modal-close { position: absolute; top: 12px; right: 16px; background: none; border: none; color: #8b949e; font-size: 20px; cursor: pointer; }
.modal-close:hover { color: #e1e4e8; }
.modal h3 { color: #e1e4e8; margin-bottom: 12px; padding-right: 30px; }
.modal-score { font-size: 24px; font-weight: 700; color: #f0883e; margin-bottom: 16px; }
.analysis-section { margin-bottom: 4px; }
.analysis-section h4 { color: #58a6ff; font-size: 13px; margin: 12px 0 4px; }
.analysis-text { color: #c9d1d9; font-size: 13px; line-height: 1.6; white-space: pre-line; }
.analysis-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
.analysis-tags .tag { background: #21262d; padding: 3px 8px; border-radius: 4px; font-size: 11px; color: #8b949e; }
.coin-detail-btn, .coin-history-btn { background: none; border: 1px solid #30363d; color: #58a6ff; font-size: 11px; padding: 2px 8px; border-radius: 4px; cursor: pointer; margin-top: 4px; }
.coin-detail-btn:hover, .coin-history-btn:hover { background: #21262d; }
.sparkline { margin-top: 4px; }
.history-chart { margin-top: 12px; }
.progress-panel { background: #1c2128; border: 1px solid #30363d; border-radius: 8px; padding: 16px; margin-bottom: 16px; display: none; }
.progress-panel.active { display: block; }
.progress-bar-wrap { background: #21262d; border-radius: 4px; height: 8px; margin: 8px 0; overflow: hidden; }
.progress-bar-fill { background: #1f6feb; height: 100%; border-radius: 4px; transition: width 0.3s; width: 0%; }
.progress-log { font-family: monospace; font-size: 12px; color: #8b949e; max-height: 200px; overflow-y: auto; margin-top: 8px; }
.progress-log div { padding: 2px 0; }
.progress-log .ok { color: #56d364; }
.progress-log .err { color: #f85149; }
.progress-log .info { color: #58a6ff; }
</style>
</head>
<body>
<div class="header">
<h1>Coin Scout</h1>
<div class="stats" id="stats"></div>
</div>
<div class="tabs">
<div class="tab active" data-tab="hot">Горячие</div>
<div class="tab" data-tab="new">Новые</div>
<div class="tab" data-tab="dashboard">Дашборд</div>
<div class="tab" data-tab="compare">Сравнение</div>
<div class="tab" data-tab="all">Все</div>
<div class="tab" data-tab="method">Методология</div>
<div class="tab" data-tab="settings">Настройки</div>
</div>
<div class="toolbar" id="toolbar">
<label>Макс. цена:
<input type="number" id="f-price" value="3000" step="100" min="0">
</label>
<label>Мин. грейд:
<select id="f-grade">
<option value="">Любой</option>
<option value="G">G</option>
<option value="VG">VG</option>
<option value="F">F</option>
<option value="VF" selected>VF</option>
<option value="XF">XF</option>
<option value="AU">AU</option>
<option value="UNC">UNC</option>
<option value="Proof">Proof</option>
</select>
</label>
<label>Материал:
<select id="f-material">
<option value="серебро,золото">Серебро/Золото</option>
<option value="любой">Любой</option>
<option value="серебро">Серебро</option>
<option value="золото">Золото</option>
</select>
</label>
<label>Страна:
<select id="f-country">
<option value="">Все</option>
</select>
</label>
<label>Магазин:
<select id="f-feed">
<option value="">Все</option>
<option value="AT">numizm.at</option>
<option value="KB">coinsbolhov.ru</option>
<option value="RU">numizmat.ru</option>
</select>
</label>
<label>
<input type="checkbox" id="f-stock" checked> В наличии
</label>
<label>
<input type="checkbox" id="f-dupes" checked> Без дублей
</label>
<button class="btn btn-primary" onclick="loadCoins()">Применить</button>
<button class="btn btn-scan" id="btn-scan" onclick="startScan()">Сканировать</button>
</div>
<div class="progress-panel" id="progress-panel">
<div style="font-size: 14px; font-weight: 500;" id="progress-title">Сканирование...</div>
<div class="progress-bar-wrap"><div class="progress-bar-fill" id="progress-bar"></div></div>
<div class="progress-log" id="progress-log"></div>
</div>
<div class="content" id="content">
<div class="loading">Загрузка...</div>
</div>
<script>
let currentTab = 'hot'
let settings = {}
const coinStore = new Map()
// Modal
document.addEventListener('click', (e) => {
const btn = e.target.closest('.coin-detail-btn')
if (btn) {
const coin = coinStore.get(btn.dataset.coinId)
if (coin) showAnalysisModal(coin)
return
}
const hBtn = e.target.closest('.coin-history-btn')
if (hBtn) {
showPriceHistory(hBtn.dataset.coinId)
return
}
if (e.target.classList.contains('modal-overlay')) {
e.target.remove()
}
})
async function showPriceHistory(coinId) {
const coin = coinStore.get(coinId)
const overlay = document.createElement('div')
overlay.className = 'modal-overlay'
overlay.innerHTML = `<div class="modal"><button class="modal-close" onclick="this.closest('.modal-overlay').remove()">&times;</button><h3>${coin ? coin.name : 'История цен'}</h3><div class="loading">Загрузка...</div></div>`
document.body.appendChild(overlay)
try {
const res = await fetch('/api/coins/' + coinId + '/history')
const d = await res.json()
const modal = overlay.querySelector('.modal')
if (!d.history.length) {
modal.querySelector('.loading').innerHTML = '<p style="color:#8b949e">Нет данных об изменении цены</p>'
return
}
const prices = d.history.map(h => h.price)
const dates = d.history.map(h => new Date(h.recorded_at).toLocaleDateString('ru'))
const min = Math.min(...prices), max = Math.max(...prices)
const W = 560, H = 200, pad = 40
const range = max - min || 1
const points = prices.map((p, i) => {
const x = pad + (i / (prices.length - 1 || 1)) * (W - pad * 2)
const y = pad + (1 - (p - min) / range) * (H - pad * 2)
return { x, y, price: p, date: dates[i] }
})
const line = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
const dots = points.map(p => `<circle cx="${p.x}" cy="${p.y}" r="4" fill="#58a6ff" stroke="#0d1117" stroke-width="2"><title>${p.date}: ${p.price}₽</title></circle>`).join('')
const yLabels = [min, min + range / 2, max].map((v, i) => {
const y = pad + (1 - (v - min) / range) * (H - pad * 2)
return `<text x="${pad - 6}" y="${y + 4}" fill="#8b949e" font-size="11" text-anchor="end">${Math.round(v)}</text>`
}).join('')
const xLabels = [0, Math.floor(dates.length / 2), dates.length - 1].filter((v, i, a) => a.indexOf(v) === i).map(i => {
return `<text x="${points[i].x}" y="${H - 5}" fill="#8b949e" font-size="10" text-anchor="middle">${dates[i]}</text>`
}).join('')
const diff = prices[prices.length - 1] - prices[0]
const diffPct = prices[0] > 0 ? Math.round(diff / prices[0] * 100) : 0
const diffColor = diff > 0 ? '#f85149' : diff < 0 ? '#56d364' : '#8b949e'
const diffSign = diff > 0 ? '+' : ''
modal.querySelector('.loading').innerHTML = `
<div style="margin-bottom:12px">
<span style="color:#e1e4e8;font-size:18px;font-weight:600">${prices[prices.length-1]}₽</span>
<span style="color:${diffColor};margin-left:8px;font-size:14px">${diffSign}${diff}₽ (${diffSign}${diffPct}%)</span>
<span style="color:#8b949e;margin-left:8px;font-size:12px">${d.history.length} записей</span>
</div>
<svg width="${W}" height="${H}" style="display:block">
<path d="${line}" fill="none" stroke="#58a6ff" stroke-width="2"/>
${dots}
${yLabels}
${xLabels}
</svg>`
} catch (e) {
overlay.querySelector('.loading').innerHTML = `<p style="color:#f85149">Ошибка: ${e.message}</p>`
}
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const m = document.querySelector('.modal-overlay')
if (m) m.remove()
}
})
function showAnalysisModal(c) {
const overlay = document.createElement('div')
overlay.className = 'modal-overlay'
const tags = (c.reasons || []).map(r => `<span class="tag">${r}</span>`).join('')
overlay.innerHTML = `
<div class="modal">
<button class="modal-close" onclick="this.closest('.modal-overlay').remove()">&times;</button>
<h3>${c.name}</h3>
<div class="modal-score">Score: ${c.score}</div>
${c.image ? `<img src="${c.image}" style="width:120px;height:120px;object-fit:cover;border-radius:8px;margin-bottom:12px" onerror="this.style.display='none'">` : ''}
<div style="margin-bottom:8px">
<span style="color:#56d364;font-size:18px;font-weight:600">${c.price} ₽</span>
${c.old_price && c.old_price > c.price ? `<span style="color:#8b949e;text-decoration:line-through;margin-left:8px">${c.old_price} ₽</span>` : ''}
<span style="color:#8b949e;margin-left:12px;font-size:13px">${feedNames[c.feed] || c.feed}</span>
</div>
<div class="analysis-text">${c.analysis || ''}</div>
${tags ? `<div class="analysis-tags">${tags}</div>` : ''}
${c.emailCopy ? `
<div style="margin-top:20px;border-top:1px solid #30363d;padding-top:16px">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px">
<h4 style="color:#f0883e;margin:0;font-size:14px">Текст для рассылки</h4>
<button class="btn" style="font-size:11px;padding:3px 10px" onclick="navigator.clipboard.writeText(this.closest('.modal').querySelector('.email-copy-text').innerText);this.textContent='Скопировано!';setTimeout(()=>this.textContent='Копировать',1500)">Копировать</button>
</div>
<div class="email-copy-text" style="background:#0d1117;border:1px solid #30363d;border-radius:8px;padding:14px;font-size:13px;color:#e1e4e8;line-height:1.7;white-space:pre-line">${c.emailCopy}</div>
</div>
` : ''}
<div style="margin-top:16px">
<a href="${c.url}" target="_blank" class="btn btn-primary" style="text-decoration:none;display:inline-block">Открыть на сайте</a>
</div>
</div>`
document.body.appendChild(overlay)
}
// Tabs
document.querySelectorAll('.tab').forEach(t => {
t.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(x => x.classList.remove('active'))
t.classList.add('active')
currentTab = t.dataset.tab
const noToolbar = ['settings', 'method', 'dashboard', 'compare']
document.getElementById('toolbar').style.display = noToolbar.includes(currentTab) ? 'none' : 'flex'
if (currentTab === 'settings') loadSettings()
else if (currentTab === 'method') loadMethodology()
else if (currentTab === 'dashboard') loadDashboard()
else if (currentTab === 'compare') loadCompare()
else if (currentTab === 'new') loadNew()
else if (currentTab === 'all') loadAll()
else loadCoins()
})
})
// Format helpers
const feedNames = { AT: 'numizm.at', KB: 'coinsbolhov.ru', RU: 'numizmat.ru' }
function materialClass(m) {
if (!m) return ''
const l = m.toLowerCase()
if (/золото|gold/.test(l)) return 'gold'
if (/серебро|silver|биллон/.test(l)) return 'silver'
return ''
}
function gradeClass(g) {
if (!g) return ''
const gs = ['G','AG','VG','F','VF','XF','EF','AU','UNC','BU','Proof']
const upper = g.toUpperCase()
for (let i = gs.length - 1; i >= 0; i--) {
if (upper.includes(gs[i])) return i >= 6 ? 'grade-high' : i >= 4 ? 'grade-mid' : ''
}
return ''
}
function coinCard(c, rank) {
const isTop = rank < 3
return `
<div class="coin-card ${isTop ? 'top' : ''}">
${c.image ? `<img class="coin-img" src="${c.image}" loading="lazy" onerror="this.style.display='none'">` : ''}
<div class="coin-info">
<div class="coin-name"><a href="${c.url}" target="_blank">${c.name}</a></div>
<div class="coin-meta">
${c.grade ? `<span class="tag ${gradeClass(c.grade)}">${c.grade}</span>` : '<span class="tag">? грейд</span>'}
${c.material ? `<span class="tag ${materialClass(c.material)}">${c.material}</span>` : ''}
${c.year_from ? `<span class="tag">${c.year_from}${c.year_to && c.year_to !== c.year_from ? '' + c.year_to : ''}</span>` : ''}
${c.in_stock === 0 ? '<span class="tag no-stock">Нет в наличии</span>' : ''}
${(c.special || []).map(s => '<span class="tag special">' + s + '</span>').join('')}
</div>
<div class="coin-price">${c.price}${c.old_price && c.old_price > c.price ? `<span class="old">${c.old_price} ₽</span>` : ''}</div>
${c.score ? `<div class="coin-score">Score: ${c.score}</div>` : ''}
${c.summary ? `<div style="font-size:11px;color:#c9d1d9;margin-top:3px;line-height:1.4">${c.summary}</div>` : ''}
${c.analysis ? `<button class="coin-detail-btn" data-coin-id="${c.id}">Подробный анализ</button>` : ''}
<button class="coin-history-btn" data-coin-id="${c.id}">История цен</button>
<div class="coin-feed">${feedNames[c.feed] || c.feed}</div>
</div>
</div>`
}
async function loadCoins() {
const content = document.getElementById('content')
content.innerHTML = '<div class="loading">Загрузка...</div>'
const params = new URLSearchParams({
max_price: document.getElementById('f-price').value,
min_grade: document.getElementById('f-grade').value,
material: document.getElementById('f-material').value,
in_stock: document.getElementById('f-stock').checked ? '1' : '0',
hide_dupes: document.getElementById('f-dupes').checked ? '1' : '0',
feed: document.getElementById('f-feed').value,
country: document.getElementById('f-country').value,
})
try {
const res = await fetch('/api/coins?' + params)
const data = await res.json()
if (!data.coins.length) {
content.innerHTML = '<div class="empty"><h3>Ничего не найдено</h3><p>Попробуйте изменить фильтры или запустить сканирование</p></div>'
return
}
data.coins.forEach(c => coinStore.set(c.id, c))
content.innerHTML = `<div class="scan-bar"><span>Найдено: <b>${data.total}</b> монет</span></div>
<div class="coin-grid">${data.coins.map((c, i) => coinCard(c, i)).join('')}</div>`
} catch (e) {
content.innerHTML = `<div class="empty"><h3>Ошибка</h3><p>${e.message}</p></div>`
}
}
async function loadNew() {
const content = document.getElementById('content')
content.innerHTML = '<div class="loading">Загрузка...</div>'
try {
const res = await fetch('/api/coins/new?days=7')
const data = await res.json()
if (!data.coins.length) {
content.innerHTML = '<div class="empty"><h3>Новых поступлений нет</h3><p>Запустите сканирование или подождите</p></div>'
return
}
data.coins.forEach(c => coinStore.set(c.id, c))
content.innerHTML = `<div class="scan-bar"><span>Новых за 7 дней: <b>${data.total}</b></span></div>
<div class="coin-grid">${data.coins.map((c, i) => coinCard(c, i)).join('')}</div>`
} catch (e) {
content.innerHTML = `<div class="empty"><h3>Ошибка</h3><p>${e.message}</p></div>`
}
}
async function loadAll() {
const content = document.getElementById('content')
content.innerHTML = '<div class="loading">Загрузка...</div>'
try {
const res = await fetch('/api/coins/all?limit=200')
const data = await res.json()
data.coins.forEach(c => coinStore.set(c.id, c))
content.innerHTML = `<div class="scan-bar"><span>Всего: <b>${data.total}</b></span></div>
<div class="coin-grid">${data.coins.map((c, i) => coinCard(c, 999)).join('')}</div>`
} catch (e) {
content.innerHTML = `<div class="empty"><h3>Ошибка</h3><p>${e.message}</p></div>`
}
}
async function loadSettings() {
const content = document.getElementById('content')
try {
const res = await fetch('/api/settings')
settings = await res.json()
content.innerHTML = `
<div class="settings-panel">
<h3 style="margin-bottom: 16px;">Настройки Coin Scout</h3>
<div class="field">
<label>Максимальная цена по умолчанию (₽)</label>
<input type="number" id="s-price" value="${settings.max_price || 3000}" step="100">
</div>
<div class="field">
<label>Минимальный грейд</label>
<select id="s-grade">
${['G','VG','F','VF','XF','AU','UNC','Proof'].map(g =>
`<option value="${g}" ${g === settings.min_grade ? 'selected' : ''}>${g}</option>`
).join('')}
</select>
</div>
<div class="field">
<label>Предпочтительный материал</label>
<input type="text" id="s-material" value="${settings.preferred_material || 'серебро,золото'}">
</div>
<div class="field">
<label>Час автосканирования (0-23)</label>
<input type="number" id="s-hour" value="${settings.auto_scan_hour || 8}" min="0" max="23">
</div>
<div class="field">
<label><input type="checkbox" id="s-enabled" ${settings.scan_enabled === '1' ? 'checked' : ''}> Автосканирование включено</label>
</div>
<div class="field">
<label><input type="checkbox" id="s-dupes" ${settings.hide_dupes === '1' ? 'checked' : ''}> Скрывать дубликаты по умолчанию</label>
</div>
<button class="btn btn-primary" onclick="saveSettings()">Сохранить</button>
<span id="s-saved" style="color: #56d364; margin-left: 12px; display: none;">Сохранено!</span>
</div>`
} catch (e) {
content.innerHTML = `<div class="empty"><p>Ошибка: ${e.message}</p></div>`
}
}
async function saveSettings() {
await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
max_price: document.getElementById('s-price').value,
min_grade: document.getElementById('s-grade').value,
preferred_material: document.getElementById('s-material').value,
auto_scan_hour: document.getElementById('s-hour').value,
scan_enabled: document.getElementById('s-enabled').checked ? '1' : '0',
hide_dupes: document.getElementById('s-dupes').checked ? '1' : '0',
}),
})
const el = document.getElementById('s-saved')
el.style.display = 'inline'
setTimeout(() => el.style.display = 'none', 2000)
}
async function startScan() {
const btn = document.getElementById('btn-scan')
btn.disabled = true
btn.textContent = 'Сканирование...'
const panel = document.getElementById('progress-panel')
const bar = document.getElementById('progress-bar')
const log = document.getElementById('progress-log')
const title = document.getElementById('progress-title')
panel.classList.add('active')
log.innerHTML = ''
bar.style.width = '0%'
function addLog(text, cls = '') {
const d = document.createElement('div')
d.className = cls
d.textContent = text
log.appendChild(d)
log.scrollTop = log.scrollHeight
}
// SSE
const es = new EventSource('/api/scan/progress')
es.onmessage = (e) => {
const data = JSON.parse(e.data)
title.textContent = data.message || 'Сканирование...'
if (data.stage === 'start') {
addLog(data.message, 'info')
} else if (data.stage === 'feed') {
addLog(data.message, 'info')
bar.style.width = ((data.feedIndex + 1) / (data.feedTotal * 2) * 100) + '%'
} else if (data.stage === 'feed_done') {
addLog(data.message, 'ok')
} else if (data.stage === 'feed_error') {
addLog(data.message, 'err')
} else if (data.stage === 'saving' || data.stage === 'details') {
if (data.total) bar.style.width = (50 + (data.current / data.total * 50)) + '%'
} else if (data.stage === 'done') {
addLog(data.message, 'ok')
bar.style.width = '100%'
es.close()
btn.disabled = false
btn.textContent = 'Сканировать'
setTimeout(() => { panel.classList.remove('active') }, 5000)
loadCoins()
loadStats()
} else if (data.stage === 'error') {
addLog(data.message, 'err')
es.close()
btn.disabled = false
btn.textContent = 'Сканировать'
}
}
es.onerror = () => {
es.close()
}
// Trigger scan
try {
await fetch('/api/scan', { method: 'POST' })
} catch (e) {
btn.disabled = false
btn.textContent = 'Сканировать'
addLog('Ошибка: ' + e.message, 'err')
}
}
async function loadStats() {
try {
const res = await fetch('/api/stats')
const data = await res.json()
document.getElementById('stats').textContent =
`${data.total} монет | ${data.withDetails} с деталями | ${data.feeds.map(f => `${feedNames[f.feed] || f.feed}: ${f.cnt}`).join(', ')}`
} catch (e) {}
}
// ─── Dashboard ───
async function loadDashboard() {
const content = document.getElementById('content')
content.innerHTML = '<div class="loading">Загрузка дашборда...</div>'
try {
const res = await fetch('/api/dashboard')
const d = await res.json()
const feedNames = { AT: 'numizm.at', KB: 'coinsbolhov.ru', RU: 'numizmat.ru' }
content.innerHTML = `
<div style="max-width:900px">
<h2 style="color:#e1e4e8;margin-bottom:16px">Дашборд</h2>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:20px">
<div style="background:#161b22;border:1px solid #30363d;border-radius:8px;padding:16px;text-align:center">
<div style="font-size:28px;font-weight:700;color:#56d364">${d.total.toLocaleString()}</div>
<div style="color:#8b949e;font-size:13px">Всего монет</div>
</div>
<div style="background:#161b22;border:1px solid #30363d;border-radius:8px;padding:16px;text-align:center">
<div style="font-size:28px;font-weight:700;color:#58a6ff">${d.newThisWeek}</div>
<div style="color:#8b949e;font-size:13px">Новых за 7 дней</div>
</div>
<div style="background:#161b22;border:1px solid #30363d;border-radius:8px;padding:16px;text-align:center">
<div style="font-size:28px;font-weight:700;color:#f0883e">${d.priceDrops.length}</div>
<div style="color:#8b949e;font-size:13px">Снижения цен</div>
</div>
<div style="background:#161b22;border:1px solid #30363d;border-radius:8px;padding:16px;text-align:center">
<div style="font-size:28px;font-weight:700;color:#c9d1d9">${d.silverPrice}</div>
<div style="color:#8b949e;font-size:13px">Серебро ₽/г (ЦБ)</div>
</div>
</div>
<div style="background:#161b22;border:1px solid #30363d;border-radius:8px;padding:14px;margin-bottom:20px">
<h3 style="color:#58a6ff;margin-bottom:8px;font-size:14px">Статистика парсинга</h3>
<div style="display:flex;align-items:center;gap:16px;margin-bottom:8px">
<div style="flex:1;background:#21262d;border-radius:4px;height:8px;overflow:hidden">
<div style="background:#238636;height:100%;width:${d.parseStats.rate}%;border-radius:4px"></div>
</div>
<span style="color:#e1e4e8;font-size:13px;font-weight:600">${d.parseStats.rate}%</span>
</div>
<div style="font-size:12px;color:#8b949e">
${d.parseStats.parsed.toLocaleString()} распарсено · ${d.parseStats.unparsed.toLocaleString()} без деталей
${d.parseStats.failsByFeed.length ? ' · Без деталей: ' + d.parseStats.failsByFeed.map(f => `${feedNames[f.feed] || f.feed}: ${f.cnt.toLocaleString()}`).join(', ') : ''}
</div>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px;margin-bottom:20px">
${d.feeds.map(f => `
<div style="background:#161b22;border:1px solid #30363d;border-radius:8px;padding:12px">
<div style="color:#e1e4e8;font-weight:600">${feedNames[f.feed] || f.feed}</div>
<div style="color:#8b949e;font-size:13px">${f.cnt.toLocaleString()} монет · средняя ${f.avg_price}₽</div>
</div>
`).join('')}
</div>
${d.dailyNew.length > 1 ? `
<div style="background:#161b22;border:1px solid #30363d;border-radius:8px;padding:14px;margin-bottom:20px">
<h3 style="color:#58a6ff;margin-bottom:10px;font-size:14px">Новые монеты по дням (14 дней)</h3>
${(() => {
const vals = d.dailyNew.map(x => x.cnt)
const max = Math.max(...vals)
const W = 560, H = 120, pad = 30
const barW = Math.max(8, Math.min(28, (W - pad * 2) / vals.length - 2))
const bars = d.dailyNew.map((x, i) => {
const bx = pad + i * ((W - pad * 2) / vals.length) + 1
const bh = max > 0 ? (x.cnt / max) * (H - pad - 10) : 0
const by = H - pad - bh
const day = x.day.substring(5)
return '<rect x="' + bx + '" y="' + by + '" width="' + barW + '" height="' + bh + '" fill="#238636" rx="2"><title>' + day + ': ' + x.cnt + '</title></rect>' +
(vals.length <= 14 ? '<text x="' + (bx + barW/2) + '" y="' + (H - 5) + '" fill="#8b949e" font-size="9" text-anchor="middle">' + day + '</text>' : '')
}).join('')
return '<svg width="' + W + '" height="' + H + '" style="display:block">' + bars + '</svg>'
})()}
</div>
` : ''}
${d.topWeek.length ? `
<h3 style="color:#56d364;margin:20px 0 10px">Топ находки за неделю</h3>
<table style="width:100%;border-collapse:collapse;font-size:13px;margin-bottom:20px">
<tr style="border-bottom:1px solid #30363d;color:#8b949e"><th style="text-align:left;padding:6px">Монета</th><th>Грейд</th><th>Материал</th><th>Цена</th><th>Магазин</th></tr>
${d.topWeek.map(c => `
<tr style="border-bottom:1px solid #21262d">
<td style="padding:6px"><a href="${c.url}" target="_blank">${c.name.substring(0,50)}</a></td>
<td style="text-align:center">${c.grade || '—'}</td>
<td style="text-align:center;color:#8b949e">${c.material || '—'}</td>
<td style="text-align:center;color:#56d364">${c.price}₽</td>
<td style="text-align:center;color:#8b949e">${feedNames[c.feed] || c.feed}</td>
</tr>
`).join('')}
</table>
` : ''}
${d.priceDrops.length ? `
<h3 style="color:#f0883e;margin:20px 0 10px">Снижения цен</h3>
<table style="width:100%;border-collapse:collapse;font-size:13px">
<tr style="border-bottom:1px solid #30363d;color:#8b949e"><th style="text-align:left;padding:6px">Монета</th><th>Было</th><th>Стало</th><th>Δ</th><th>Магазин</th></tr>
${d.priceDrops.map(c => `
<tr style="border-bottom:1px solid #21262d">
<td style="padding:6px"><a href="${c.url}" target="_blank">${c.name.substring(0,50)}</a></td>
<td style="text-align:center;color:#8b949e">${c.prev_price}₽</td>
<td style="text-align:center;color:#56d364">${c.price}₽</td>
<td style="text-align:center;color:#f0883e">${c.drop_pct}%</td>
<td style="text-align:center;color:#8b949e">${feedNames[c.feed] || c.feed}</td>
</tr>
`).join('')}
</table>
` : ''}
${d.priceRises.length ? `
<h3 style="color:#f85149;margin:20px 0 10px">Повышения цен</h3>
<table style="width:100%;border-collapse:collapse;font-size:13px">
<tr style="border-bottom:1px solid #30363d;color:#8b949e"><th style="text-align:left;padding:6px">Монета</th><th>Было</th><th>Стало</th><th>Δ</th><th>Магазин</th></tr>
${d.priceRises.slice(0,10).map(c => `
<tr style="border-bottom:1px solid #21262d">
<td style="padding:6px"><a href="${c.url}" target="_blank">${c.name.substring(0,50)}</a></td>
<td style="text-align:center;color:#8b949e">${c.prev_price}₽</td>
<td style="text-align:center;color:#f85149">${c.price}₽</td>
<td style="text-align:center;color:#f85149">+${c.rise_pct}%</td>
<td style="text-align:center;color:#8b949e">${feedNames[c.feed] || c.feed}</td>
</tr>
`).join('')}
</table>
` : ''}
${d.disappeared.length ? `
<h3 style="color:#8b949e;margin:20px 0 10px">Исчезли за 7 дней (проданы?)</h3>
<div style="font-size:13px;color:#8b949e">
${d.disappeared.map(c => `<div style="padding:3px 0">${c.name.substring(0,60)} · ${c.price}₽ · ${feedNames[c.feed] || c.feed}</div>`).join('')}
</div>
` : ''}
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:20px">
<div>
<h3 style="color:#58a6ff;margin-bottom:8px">По материалу</h3>
<table style="width:100%;font-size:12px;border-collapse:collapse">
${d.materials.map(m => `<tr style="border-bottom:1px solid #21262d"><td style="padding:3px">${m.material}</td><td style="text-align:right">${m.cnt}</td><td style="text-align:right;color:#8b949e">~${m.avg_price}₽</td></tr>`).join('')}
</table>
</div>
<div>
<h3 style="color:#58a6ff;margin-bottom:8px">По грейду</h3>
<table style="width:100%;font-size:12px;border-collapse:collapse">
${d.grades.map(g => `<tr style="border-bottom:1px solid #21262d"><td style="padding:3px">${g.grade}</td><td style="text-align:right">${g.cnt}</td><td style="text-align:right;color:#8b949e">~${g.avg_price}₽</td></tr>`).join('')}
</table>
</div>
</div>
</div>`
} catch (e) {
content.innerHTML = `<div class="empty"><p>Ошибка: ${e.message}</p></div>`
}
}
// ─── Cross-store comparison ───
async function loadCompare() {
const content = document.getElementById('content')
content.innerHTML = '<div class="loading">Сравнение магазинов...</div>'
try {
const res = await fetch('/api/compare?min_diff=50&limit=1000')
const d = await res.json()
const feedNames = { AT: 'numizm.at', KB: 'coinsbolhov.ru', RU: 'numizmat.ru' }
if (!d.comparisons.length) {
content.innerHTML = '<div class="empty"><h3>Совпадений не найдено</h3><p>Нужно больше сканов чтобы найти одинаковые монеты в разных магазинах</p></div>'
return
}
content.innerHTML = `
<div style="max-width:1000px">
<div style="display:flex;align-items:center;gap:16px;margin-bottom:16px">
<h2 style="color:#e1e4e8;margin:0">Сравнение магазинов</h2>
<span style="color:#8b949e;font-size:13px">${d.comparisons.length} совпадений с разницей ≥50%</span>
<a class="btn" href="/api/compare/csv?min_diff=50&limit=5000" style="margin-left:auto;text-decoration:none">Экспорт CSV</a>
<button class="btn" onclick="exportComparePdf()">Экспорт PDF</button>
</div>
<p style="color:#8b949e;margin-bottom:16px;font-size:13px">Одинаковые монеты (грейд + материал) в разных магазинах. Зелёным выделена меньшая цена.</p>
<table id="compare-table" style="width:100%;border-collapse:collapse;font-size:13px">
<tr style="border-bottom:1px solid #30363d;color:#8b949e">
<th style="text-align:left;padding:8px">Монета</th>
<th>Грейд</th>
<th>Материал</th>
<th>Скор</th>
<th>Фото 1</th><th>Магазин 1</th><th>Цена 1</th>
<th>Фото 2</th><th>Магазин 2</th><th>Цена 2</th>
<th>Разница</th>
<th title="Визуальное сходство фото. Низкий % может означать разные стороны (аверс/реверс)">Сходство</th>
</tr>
${d.comparisons.map(c => {
const cheaper = c.price1 <= c.price2 ? 1 : 2
return `<tr style="border-bottom:1px solid #21262d">
<td style="padding:8px;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${c.name.substring(0,55)}</td>
<td style="text-align:center;color:#8b949e;font-size:12px">${c.grade1 || '—'}</td>
<td style="text-align:center;color:#8b949e;font-size:12px">${c.material || c.material1 || '—'}</td>
<td style="text-align:center;color:#f0883e;font-weight:600;font-size:12px">${c.score || '—'}</td>
<td style="text-align:center">${c.image1 ? `<a href="${c.url1}" target="_blank"><img src="${c.image1}" style="width:40px;height:40px;object-fit:cover;border-radius:4px" onerror="this.style.display='none'"></a>` : '—'}</td>
<td style="text-align:center"><a href="${c.url1}" target="_blank">${feedNames[c.feed1] || c.feed1}</a></td>
<td style="text-align:center;${cheaper===1?'color:#56d364;font-weight:600':''}">${c.price1}₽</td>
<td style="text-align:center">${c.image2 ? `<a href="${c.url2}" target="_blank"><img src="${c.image2}" style="width:40px;height:40px;object-fit:cover;border-radius:4px" onerror="this.style.display='none'"></a>` : '—'}</td>
<td style="text-align:center"><a href="${c.url2}" target="_blank">${feedNames[c.feed2] || c.feed2}</a></td>
<td style="text-align:center;${cheaper===2?'color:#56d364;font-weight:600':''}">${c.price2}₽</td>
<td style="text-align:center;color:#f0883e">${c.diff_pct}%</td>
<td style="text-align:center;font-size:12px" class="sim-cell" data-idx="${d.comparisons.indexOf(c)}">...</td>
</tr>`
}).join('')}
</table>
</div>`
// Store data for PDF export
window._compareData = d.comparisons
// Load visual similarity in batches
loadSimilarities(d.comparisons)
} catch (e) {
content.innerHTML = `<div class="empty"><p>Ошибка: ${e.message}</p></div>`
}
}
async function loadSimilarities(comparisons) {
const BATCH = 20
for (let i = 0; i < comparisons.length; i += BATCH) {
const batch = comparisons.slice(i, i + BATCH)
const pairs = batch.map(c => ({ image1: c.image1 || '', image2: c.image2 || '' }))
try {
const res = await fetch('/api/compare/similarity', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pairs })
})
const d = await res.json()
d.similarities.forEach((sim, j) => {
const idx = i + j
const cell = document.querySelector(`.sim-cell[data-idx="${idx}"]`)
if (!cell) return
comparisons[idx]._similarity = sim
if (sim === null) { cell.textContent = '—'; return }
const color = sim >= 90 ? '#56d364' : sim >= 70 ? '#e3b341' : '#f85149'
cell.innerHTML = `<span style="color:${color};font-weight:600">${sim}%</span>`
})
} catch (e) {
// Fill remaining with —
for (let j = 0; j < batch.length; j++) {
const cell = document.querySelector(`.sim-cell[data-idx="${i + j}"]`)
if (cell) cell.textContent = '—'
}
}
}
}
function exportComparePdf() {
const data = window._compareData
if (!data || !data.length) return
const feedNames = { AT: 'numizm.at', KB: 'coinsbolhov.ru', RU: 'numizmat.ru' }
const rows = data.map(c => {
const cheaper = c.price1 <= c.price2 ? 1 : 2
return `<tr>
<td style="padding:4px 6px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${c.name.substring(0,50)}</td>
<td style="text-align:center">${c.grade1 || '—'}</td>
<td style="text-align:center">${c.material || c.material1 || '—'}</td>
<td style="text-align:center;font-weight:600">${c.score || '—'}</td>
<td style="text-align:center">${c.image1 ? `<a href="${c.url1}"><img src="${c.image1}" style="width:30px;height:30px;object-fit:cover"></a>` : ''}</td>
<td style="text-align:center"><a href="${c.url1}">${feedNames[c.feed1] || c.feed1}</a></td>
<td style="text-align:center;${cheaper===1?'font-weight:700;color:#1a7f37':''}">${c.price1}₽</td>
<td style="text-align:center">${c.image2 ? `<a href="${c.url2}"><img src="${c.image2}" style="width:30px;height:30px;object-fit:cover"></a>` : ''}</td>
<td style="text-align:center"><a href="${c.url2}">${feedNames[c.feed2] || c.feed2}</a></td>
<td style="text-align:center;${cheaper===2?'font-weight:700;color:#1a7f37':''}">${c.price2}₽</td>
<td style="text-align:center;font-weight:600;color:#c45500">${c.diff_pct}%</td>
<td style="text-align:center">${c._similarity != null ? c._similarity + '%' : '—'}</td>
</tr>`
}).join('')
const w = window.open('', '_blank')
w.document.write(`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Coin Scout — Сравнение магазинов</title>
<style>
body { font-family: -apple-system, sans-serif; font-size: 10px; color: #1a1a1a; padding: 20px; }
h1 { font-size: 18px; margin-bottom: 4px; }
.meta { color: #666; font-size: 11px; margin-bottom: 12px; }
table { width: 100%; border-collapse: collapse; }
th { background: #f5f5f5; text-align: left; padding: 5px 6px; border: 1px solid #ddd; font-size: 10px; }
td { padding: 4px 6px; border: 1px solid #eee; font-size: 10px; }
tr:nth-child(even) { background: #fafafa; }
a { color: #0366d6; text-decoration: none; }
@media print { a { color: #0366d6; } }
</style></head><body>
<h1>Coin Scout — Сравнение магазинов</h1>
<div class="meta">${data.length} совпадений с разницей ≥50% · ${new Date().toLocaleDateString('ru')} · Зелёным выделена меньшая цена</div>
<table>
<tr><th>Монета</th><th>Грейд</th><th>Материал</th><th>Скор</th><th>Фото</th><th>Магазин 1</th><th>Цена 1</th><th>Фото</th><th>Магазин 2</th><th>Цена 2</th><th>Δ</th><th>Сход.</th></tr>
${rows}
</table>
<script>window.onload=()=>window.print()<\/script>
</body></html>`)
w.document.close()
}
function loadMethodology() {
document.getElementById('content').innerHTML = `
<div style="max-width:800px;line-height:1.7;font-size:14px">
<div style="display:flex;align-items:center;gap:16px;margin-bottom:16px">
<h2 style="margin:0;color:#e1e4e8">Методология оценки инвестиционной привлекательности монет</h2>
<a href="/methodology.html?print" target="_blank" class="btn btn-primary" style="text-decoration:none;white-space:nowrap">Скачать PDF</a>
</div>
<p style="color:#8b949e;margin-bottom:20px">Скоринг основан на анализе <b>40+ профессиональных источников</b> по нумизматике: PCGS, NGC, Forbes.ru, numizmatik.ru, coinweek.com, zolotoy-zapas.ru, numisdon.com, raritetus.ru и др. Цена серебра: ~200₽/грамм (ЦБ РФ, апрель 2026).</p>
<h3 style="color:#f0883e;margin:24px 0 12px">1. Сохранность / Грейд (до 30 очков)</h3>
<p style="color:#8b949e;margin-bottom:8px">Каждый шаг грейда может увеличить цену в 2-50 раз. Переход AU→UNC — самый большой скачок.</p>
<table style="width:100%;border-collapse:collapse;margin-bottom:12px">
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">Proof</td><td style="padding:4px 8px;color:#56d364">+28</td><td style="padding:4px 8px;color:#8b949e">Зеркальная поверхность, высшее качество</td></tr>
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">UNC</td><td style="padding:4px 8px;color:#56d364">+25</td><td style="padding:4px 8px;color:#8b949e">Не была в обращении</td></tr>
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">AU</td><td style="padding:4px 8px;color:#56d364">+20</td><td style="padding:4px 8px;color:#8b949e">Почти без следов обращения</td></tr>
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">XF</td><td style="padding:4px 8px;color:#56d364">+15</td><td style="padding:4px 8px;color:#8b949e">Лёгкий износ на выступающих частях</td></tr>
<tr><td style="padding:4px 8px;color:#8b949e">VF</td><td style="padding:4px 8px;color:#56d364">+8</td><td style="padding:4px 8px;color:#8b949e">Умеренный износ, все детали читаемы</td></tr>
</table>
<p style="color:#8b949e">Бонус: VF+ для монет до 1800 г. (+8), XF для XIX века (+5) — исключительная сохранность для возраста.</p>
<h3 style="color:#f0883e;margin:24px 0 12px">2. Материал и стоимость металла (до 25 очков)</h3>
<p style="color:#8b949e;margin-bottom:8px">Драгоценный металл создаёт «пол» стоимости — монета не может стоить дешевле металла.</p>
<table style="width:100%;border-collapse:collapse;margin-bottom:12px">
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">Золото</td><td style="padding:4px 8px;color:#56d364">+22</td><td style="padding:4px 8px;color:#8b949e">Надёжный актив, всегда ликвиден</td></tr>
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">Серебро</td><td style="padding:4px 8px;color:#56d364">+14</td><td style="padding:4px 8px;color:#8b949e">+ бонус если цена близка к стоимости металла</td></tr>
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">Серебро &lt; melt</td><td style="padding:4px 8px;color:#56d364">+10 доп.</td><td style="padding:4px 8px;color:#8b949e">Дешевле стоимости серебра — безрисковая покупка</td></tr>
<tr><td style="padding:4px 8px;color:#8b949e">Биллон</td><td style="padding:4px 8px;color:#56d364">+6</td><td style="padding:4px 8px;color:#8b949e">Сплав с серебром</td></tr>
</table>
<h3 style="color:#f0883e;margin:24px 0 12px">3. Возраст и историческая значимость (до 20 очков)</h3>
<p style="color:#8b949e;margin-bottom:8px">Античные монеты показывают 8-15% годового роста (numisdon.com). Возраст + сохранность = экспоненциальный рост ценности.</p>
<table style="width:100%;border-collapse:collapse;margin-bottom:12px">
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">До н.э.</td><td style="padding:4px 8px;color:#56d364">+20</td><td style="padding:4px 8px;color:#8b949e">Античная монета</td></tr>
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">500+ лет</td><td style="padding:4px 8px;color:#56d364">+18</td><td style="padding:4px 8px;color:#8b949e">Средневековье</td></tr>
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">300+ лет</td><td style="padding:4px 8px;color:#56d364">+14</td><td style="padding:4px 8px;color:#8b949e">Раннее Новое время</td></tr>
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">200+ лет</td><td style="padding:4px 8px;color:#56d364">+10</td><td style="padding:4px 8px;color:#8b949e"></td></tr>
<tr><td style="padding:4px 8px;color:#8b949e">100+ лет</td><td style="padding:4px 8px;color:#56d364">+6</td><td style="padding:4px 8px;color:#8b949e"></td></tr>
</table>
<h3 style="color:#f0883e;margin:24px 0 12px">4. Российские премиум-периоды (до 15 очков)</h3>
<p style="color:#8b949e;margin-bottom:8px">Царские монеты растут 10-15% в год (Forbes.ru). Особые периоды и правители:</p>
<table style="width:100%;border-collapse:collapse;margin-bottom:12px">
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">СССР 1947, 1958</td><td style="padding:4px 8px;color:#56d364">+15</td><td style="padding:4px 8px;color:#8b949e">Не выпущены в обращение, раритет</td></tr>
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">Пробные монеты</td><td style="padding:4px 8px;color:#56d364">+12</td><td style="padding:4px 8px;color:#8b949e">Коллекционная элита</td></tr>
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">Смутное время (1610-1612)</td><td style="padding:4px 8px;color:#56d364">+10</td><td style="padding:4px 8px;color:#8b949e">Редчайший период русской нумизматики</td></tr>
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">Перепутки</td><td style="padding:4px 8px;color:#56d364">+10</td><td style="padding:4px 8px;color:#8b949e">Монета на чужой заготовке</td></tr>
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">Раннее СССР серебро (1921-31)</td><td style="padding:4px 8px;color:#56d364">+7</td><td style="padding:4px 8px;color:#8b949e">Малые тиражи серебра</td></tr>
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">Николай II (1894-1917)</td><td style="padding:4px 8px;color:#56d364">+6</td><td style="padding:4px 8px;color:#8b949e">Культовый период, всегда в спросе</td></tr>
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">Пётр I (1682-1725)</td><td style="padding:4px 8px;color:#56d364">+6</td><td style="padding:4px 8px;color:#8b949e">Реформатор, высокий спрос</td></tr>
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">Медный бунт (1654-1656)</td><td style="padding:4px 8px;color:#56d364">+6</td><td style="padding:4px 8px;color:#8b949e">Историческое событие</td></tr>
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">Сибирские монеты</td><td style="padding:4px 8px;color:#56d364">+6</td><td style="padding:4px 8px;color:#8b949e">Медь с примесью серебра и золота</td></tr>
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">Георгий Победоносец</td><td style="padding:4px 8px;color:#56d364">+6</td><td style="padding:4px 8px;color:#8b949e">Максимальная ликвидность</td></tr>
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">Александр II, III</td><td style="padding:4px 8px;color:#56d364">+5</td><td style="padding:4px 8px;color:#8b949e">Растущий спрос / короткое правление</td></tr>
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">Павел I (1796-1801)</td><td style="padding:4px 8px;color:#56d364">+5</td><td style="padding:4px 8px;color:#8b949e">Короткое правление — мало монет</td></tr>
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">1 рубль 1924 серебро</td><td style="padding:4px 8px;color:#56d364">+5</td><td style="padding:4px 8px;color:#8b949e">Недооценён относительно редкости</td></tr>
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">Серебряный рубль Империи</td><td style="padding:4px 8px;color:#56d364">+5</td><td style="padding:4px 8px;color:#8b949e">Топ коллекционного спроса</td></tr>
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">Екатерина II, Анна, Елизавета</td><td style="padding:4px 8px;color:#56d364">+4</td><td style="padding:4px 8px;color:#8b949e">Популярные периоды</td></tr>
<tr><td style="padding:4px 8px;color:#8b949e">Русская Финляндия серебро</td><td style="padding:4px 8px;color:#56d364">+4</td><td style="padding:4px 8px;color:#8b949e">Узкая серия</td></tr>
</table>
<h3 style="color:#f0883e;margin:24px 0 12px">5. Мировые монеты (до 10 очков)</h3>
<table style="width:100%;border-collapse:collapse;margin-bottom:12px">
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">Древняя Греция</td><td style="padding:4px 8px;color:#56d364">+6</td><td style="padding:4px 8px;color:#8b949e">~15% годового роста</td></tr>
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">Византия</td><td style="padding:4px 8px;color:#56d364">+5 (+4 золото)</td><td style="padding:4px 8px;color:#8b949e">Доступная античная империя</td></tr>
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">Римская империя</td><td style="padding:4px 8px;color:#56d364">+5</td><td style="padding:4px 8px;color:#8b949e">Растущий глобальный рынок</td></tr>
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">Боспорское царство</td><td style="padding:4px 8px;color:#56d364">+5</td><td style="padding:4px 8px;color:#8b949e">Крымская античность</td></tr>
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">Сефевиды, Талеры, Панды</td><td style="padding:4px 8px;color:#56d364">+5</td><td style="padding:4px 8px;color:#8b949e">Недооценённые сегменты</td></tr>
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">Османская империя</td><td style="padding:4px 8px;color:#56d364">+4</td><td style="padding:4px 8px;color:#8b949e">Недооценённый сегмент</td></tr>
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">Соверены, Лунные серии</td><td style="padding:4px 8px;color:#56d364">+4</td><td style="padding:4px 8px;color:#8b949e">Стабильный глобальный спрос</td></tr>
<tr><td style="padding:4px 8px;color:#8b949e">Японские монеты 100+ лет</td><td style="padding:4px 8px;color:#56d364">+4</td><td style="padding:4px 8px;color:#8b949e">Бум азиатской нумизматики</td></tr>
</table>
<h3 style="color:#f0883e;margin:24px 0 12px">6. Ошибки чеканки и разновидности (до 15 очков)</h3>
<table style="width:100%;border-collapse:collapse;margin-bottom:12px">
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">Мул / двойной аверс</td><td style="padding:4px 8px;color:#56d364">+15</td><td style="padding:4px 8px;color:#8b949e">Крайне редкий брак</td></tr>
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">Брак чеканки (смещение, раскол, перечекан...)</td><td style="padding:4px 8px;color:#56d364">+12</td><td style="padding:4px 8px;color:#8b949e">Коллекционная редкость</td></tr>
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">Ефимок / надчеканка</td><td style="padding:4px 8px;color:#56d364">+10</td><td style="padding:4px 8px;color:#8b949e">Нумизматическая элита</td></tr>
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">Серия ЧЯП (10₽ биметалл)</td><td style="padding:4px 8px;color:#56d364">+10</td><td style="padding:4px 8px;color:#8b949e">Малотиражная, высокий спрос</td></tr>
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">Без знака монетного двора</td><td style="padding:4px 8px;color:#56d364">+8</td><td style="padding:4px 8px;color:#8b949e">Редкий вариант</td></tr>
<tr><td style="padding:4px 8px;color:#8b949e">Широкий кант 1997/1998 ММД</td><td style="padding:4px 8px;color:#56d364">+8</td><td style="padding:4px 8px;color:#8b949e">Редкий производственный вариант</td></tr>
</table>
<h3 style="color:#f0883e;margin:24px 0 12px">7. Ценовая эффективность (до 12 очков)</h3>
<p style="color:#8b949e;margin-bottom:8px">Главное правило: покупай самую редкую монету в лучшем состоянии за доступную цену.</p>
<table style="width:100%;border-collapse:collapse;margin-bottom:12px">
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">Скидка 30%+</td><td style="padding:4px 8px;color:#56d364">+8</td><td style="padding:4px 8px;color:#8b949e">Сильное снижение цены</td></tr>
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">AU+ дешевле 500₽</td><td style="padding:4px 8px;color:#56d364">+6</td><td style="padding:4px 8px;color:#8b949e">Ниже рыночной стоимости</td></tr>
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">Скидка 15%+</td><td style="padding:4px 8px;color:#56d364">+5</td><td style="padding:4px 8px;color:#8b949e"></td></tr>
<tr><td style="padding:4px 8px;color:#8b949e">UNC до 1000₽</td><td style="padding:4px 8px;color:#56d364">+4</td><td style="padding:4px 8px;color:#8b949e">Выгодная цена для грейда</td></tr>
</table>
<h3 style="color:#f85149;margin:24px 0 12px">8. Штрафы (негативные факторы)</h3>
<table style="width:100%;border-collapse:collapse;margin-bottom:12px">
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">Копия / жетон / сувенир</td><td style="padding:4px 8px;color:#f85149">-20</td><td style="padding:4px 8px;color:#8b949e">Не оригинальная монета</td></tr>
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">Массовые юбилейные СССР</td><td style="padding:4px 8px;color:#f85149">-12</td><td style="padding:4px 8px;color:#8b949e">Не Proof, не драгмет — нет вторичного рынка</td></tr>
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">Чищеная монета</td><td style="padding:4px 8px;color:#f85149">-10</td><td style="padding:4px 8px;color:#8b949e">Потеря 50-90% коллекционной ценности (PCGS)</td></tr>
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">Современные памятные ЦБ РФ</td><td style="padding:4px 8px;color:#f85149">-8</td><td style="padding:4px 8px;color:#8b949e">Слабый вторичный рынок</td></tr>
<tr style="border-bottom:1px solid #30363d"><td style="padding:4px 8px;color:#8b949e">Медно-никель после 1950</td><td style="padding:4px 8px;color:#f85149">-4</td><td style="padding:4px 8px;color:#8b949e">Массовый чекан, нет Proof</td></tr>
<tr><td style="padding:4px 8px;color:#8b949e">Проволочные монеты (чешуя)</td><td style="padding:4px 8px;color:#f85149">-2</td><td style="padding:4px 8px;color:#8b949e">Высокий риск подделки</td></tr>
</table>
<h3 style="color:#58a6ff;margin:24px 0 12px">Источники (40+)</h3>
<div style="color:#8b949e;font-size:12px;columns:2;column-gap:24px">
<p><a href="https://www.pcgs.com/prices/us" target="_blank">PCGS Price Guide</a></p>
<p><a href="https://www.ngccoin.com/price-guide/" target="_blank">NGC Price Guide</a></p>
<p><a href="https://www.forbes.ru/finansy-i-investicii/356677" target="_blank">Forbes.ru — Ценная мелочь</a></p>
<p><a href="https://www.numizmatik.ru/biblio/top-redkikh-monet-2025-2026" target="_blank">numizmatik.ru — Редкие монеты 2025-2026</a></p>
<p><a href="https://www.zolotoy-zapas.ru/why-gold-coins/useful/" target="_blank">Золотой Запас — гайды</a></p>
<p><a href="https://rarecoins.ru/stati/kriterii-ocenki-stoimosti-monet-v-numizmatike.html" target="_blank">Аукционный дом Редкие Монеты</a></p>
<p><a href="https://www.numisdon.com/ancient-coins-good-investment-2025/" target="_blank">NumisDon — Ancient coins 2025</a></p>
<p><a href="https://thebullionbank.com/blog/undervalued-silver-coins-2025" target="_blank">BullionBank — Undervalued Silver</a></p>
<p><a href="https://thecoinsexplorer.com/best-rare-coins-investment-2025/" target="_blank">CoinsExplorer — Best Rare Coins</a></p>
<p><a href="https://coinweek.com/patina-on-ancient-bronze-coins/" target="_blank">CoinWeek — Patina Guide</a></p>
<p><a href="https://www.raritetus.ru/stoimost-monet/" target="_blank">Raritetus — каталог цен</a></p>
<p><a href="https://www.cbr.ru/hd_base/metall/metall_base_new/" target="_blank">ЦБ РФ — цена серебра</a></p>
<p><a href="https://www.pcgs.com/news/how-to-determine-the-value-of-a-mint-error-coin" target="_blank">PCGS — Error Coin Guide</a></p>
<p><a href="https://www.numizmatik.ru/biblio/brak-na-monetakh" target="_blank">numizmatik.ru — Брак монет</a></p>
<p><a href="https://www.russian-money.ru/articles/1rub-1997-shirokii-kant" target="_blank">russian-money.ru — Широкий кант</a></p>
<p><a href="https://coins.tsbnk.ru/" target="_blank">ТрансСтройБанк — каталог</a></p>
<p><a href="https://www.usagold.com/gold-silver-ratio-guide/" target="_blank">USAGOLD — Gold/Silver Ratio</a></p>
<p><a href="https://www.rarecoins101.com/coin-investments.html" target="_blank">RareCoins101 — Returns</a></p>
<p><a href="https://lermontovgallery.ru/spravochnik-antikvariata/" target="_blank">Лермонтов — царские монеты</a></p>
<p><a href="https://www.numisdon.com/byzantine-coins-value/" target="_blank">NumisDon — Byzantine Value</a></p>
<p><a href="https://stonexbullion.com/en/blog/rise-of-asian-bullion-coins/" target="_blank">StoneX — Asian Bullion Coins</a></p>
<p><a href="https://www.greysheet.com/news/story/five-coin-collecting-investment-mistakes-to-avoid" target="_blank">Greysheet — 5 Mistakes to Avoid</a></p>
<p><a href="https://www.marketresearchfuture.com/reports/coin-collecting-market-22623" target="_blank">Market Research — 8% CAGR</a></p>
<p><a href="https://www.coingraderai.com/blog/natural-vs-artificial-coin-toning-grading" target="_blank">CoinGraderAI — Toning</a></p>
<p><a href="https://deigoldandsilvercoins.com/top-underappreciated-world-coins-with-hidden-long-term-value/" target="_blank">DEI — Underappreciated World Coins</a></p>
</div>
<h3 style="color:#58a6ff;margin:24px 0 12px">Статистика рынка</h3>
<ul style="color:#8b949e">
<li>Средний рост рынка монет: <b>8% в год</b> (CAGR, прогноз до 2035)</li>
<li>PCGS Key Dates Index: <b>4.8% годовых</b> за 25 лет (2000-2025)</li>
<li>Топовые монеты: <b>12-14% годовых</b> (PCGS3000 Index с 1971)</li>
<li>Царские монеты: <b>10-15% годовых</b> (Forbes.ru)</li>
<li>Античные монеты: <b>8-15% годовых</b> (numisdon.com)</li>
<li>Рекомендуемый горизонт инвестиций: <b>7-10 лет минимум</b></li>
<li>Дилерская наценка: 10-30% — монета должна вырасти на столько же для безубытка</li>
</ul>
<h3 style="color:#58a6ff;margin:24px 0 12px">Фильтрация</h3>
<p style="color:#8b949e">Из фидов автоматически исключаются: открытки, альбомы, капсулы, листы, подставки, футляры, лупы, пинцеты, рамки, планшеты, холдеры, книги.</p>
<p style="color:#8b949e;margin-top:24px;font-size:12px"><i>Disclaimer: скоринг является аналитическим инструментом, не финансовой рекомендацией. Всегда проверяйте монету лично перед покупкой.</i></p>
</div>`
}
// Load country filter options
async function loadCountries() {
try {
const res = await fetch('/api/countries')
const d = await res.json()
const sel = document.getElementById('f-country')
for (const c of d.countries.slice(0, 30)) {
const opt = document.createElement('option')
opt.value = c.name
opt.textContent = `${c.name} (${c.cnt})`
sel.appendChild(opt)
}
} catch (e) {}
}
// Init
loadStats()
loadCountries()
loadCoins()
</script>
</body>
</html>