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:
@@ -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()">×</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>
|
||||
|
||||
Reference in New Issue
Block a user