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
This commit is contained in:
2026-04-13 12:39:25 +05:00
parent 718821fdd6
commit d34f04e922
5 changed files with 1115 additions and 236 deletions

View File

@@ -49,6 +49,7 @@
.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; }
@@ -83,8 +84,10 @@
.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 { 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 { background: #21262d; }
.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; }
@@ -138,6 +141,11 @@
<option value="золото">Золото</option>
</select>
</label>
<label>Страна:
<select id="f-country">
<option value="">Все</option>
</select>
</label>
<label>Магазин:
<select id="f-feed">
<option value="">Все</option>
@@ -178,10 +186,69 @@
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')
@@ -271,11 +338,13 @@
${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>`
@@ -291,6 +360,7 @@
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)
@@ -488,7 +558,7 @@
<div style="max-width:900px">
<h2 style="color:#e1e4e8;margin-bottom:16px">Дашборд</h2>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:20px">
<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>
@@ -501,6 +571,24 @@
<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">
@@ -512,6 +600,43 @@
`).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">
@@ -576,7 +701,7 @@
const content = document.getElementById('content')
content.innerHTML = '<div class="loading">Сравнение магазинов...</div>'
try {
const res = await fetch('/api/compare')
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) {
@@ -584,34 +709,129 @@
return
}
content.innerHTML = `
<div style="max-width:900px">
<h2 style="color:#e1e4e8;margin-bottom:16px">Сравнение магазинов</h2>
<p style="color:#8b949e;margin-bottom:16px;font-size:13px">Одинаковые монеты в разных магазинах, отсортированные по разнице в цене.</p>
<table style="width:100%;border-collapse:collapse;font-size:13px">
<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>Магазин 1</th><th>Цена 1</th>
<th>Магазин 2</th><th>Цена 2</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:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${c.name.substring(0,50)}</td>
<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">
@@ -760,8 +980,24 @@
</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>