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:
230
coin-scout/public/about.html
Normal file
230
coin-scout/public/about.html
Normal file
@@ -0,0 +1,230 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Coin Scout — Как это работает</title>
|
||||
<style>
|
||||
@media print {
|
||||
body { font-size: 11px; }
|
||||
h1 { font-size: 22px; }
|
||||
h2 { font-size: 16px; }
|
||||
h3 { font-size: 13px; }
|
||||
.no-break { page-break-inside: avoid; }
|
||||
.page-break { page-break-before: always; }
|
||||
a { color: #333; }
|
||||
}
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Georgia, serif; max-width: 750px; margin: 40px auto; padding: 0 20px; color: #1a1a1a; line-height: 1.7; }
|
||||
h1 { text-align: center; margin-bottom: 4px; font-size: 26px; }
|
||||
.subtitle { text-align: center; color: #666; margin-bottom: 30px; font-size: 13px; }
|
||||
h2 { color: #c45500; border-bottom: 2px solid #c45500; padding-bottom: 4px; margin-top: 32px; }
|
||||
h3 { color: #333; margin-top: 20px; }
|
||||
.box { background: #f8f9fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 14px 18px; margin: 14px 0; }
|
||||
.box b { color: #c45500; }
|
||||
.box-accent { background: #fff8f0; border-color: #c45500; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 10px 0 16px; font-size: 12px; }
|
||||
th { background: #f5f5f5; text-align: left; padding: 6px 8px; border: 1px solid #ddd; font-weight: 600; }
|
||||
td { padding: 5px 8px; border: 1px solid #ddd; }
|
||||
.green { color: #1a7f37; font-weight: 600; }
|
||||
.orange { color: #c45500; font-weight: 600; }
|
||||
.note { color: #666; font-size: 12px; font-style: italic; }
|
||||
ul { margin: 6px 0; padding-left: 20px; }
|
||||
li { margin-bottom: 5px; }
|
||||
.diagram { text-align: center; margin: 20px 0; font-family: monospace; font-size: 12px; background: #f8f9fa; padding: 16px; border-radius: 6px; border: 1px solid #e1e4e8; }
|
||||
.number { display: inline-block; width: 28px; height: 28px; line-height: 28px; text-align: center; background: #c45500; color: white; border-radius: 50%; font-weight: 700; font-size: 14px; margin-right: 8px; vertical-align: middle; }
|
||||
.step { margin: 16px 0; }
|
||||
blockquote { border-left: 3px solid #c45500; margin: 12px 0; padding: 8px 16px; background: #fff8f0; font-style: italic; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>Coin Scout</h1>
|
||||
<p class="subtitle">Система автоматического поиска недооценённых монет<br>в нумизматических интернет-магазинах</p>
|
||||
|
||||
<h2>Проблема</h2>
|
||||
|
||||
<p>На российском нумизматическом рынке работают десятки интернет-магазинов. В каждом — десятки тысяч позиций. Одна и та же монета в одном магазине может стоить 265₽, а в другом — 5 280₽. При этом характеристики идентичны: тот же год, тот же материал, тот же грейд сохранности.</p>
|
||||
|
||||
<p>Для опытного нумизмата это возможность. Но вручную отслеживать 190 000 позиций в трёх магазинах, сравнивать цены, оценивать перспективность — невозможно физически. Человек способен просмотреть 50–100 монет в день. Система просматривает все 190 000 за минуты.</p>
|
||||
|
||||
<div class="box box-accent">
|
||||
<b>Суть:</b> Coin Scout ежедневно сканирует три крупнейших нумизматических магазина России, оценивает каждую монету по 8 критериям, находит ценовые аномалии и выдаёт список лучших возможностей — монет, которые стоят дешевле, чем должны.
|
||||
</div>
|
||||
|
||||
<h2>Источники данных</h2>
|
||||
|
||||
<p>Система работает с товарными фидами (XML-каталогами) трёх магазинов:</p>
|
||||
|
||||
<div class="no-break">
|
||||
<table>
|
||||
<tr><th>Магазин</th><th>Позиций</th><th>Специализация</th></tr>
|
||||
<tr><td><b>numizm.at</b></td><td>~62 000</td><td>Широкий ассортимент: Россия, Европа, Азия. Много мировых монет.</td></tr>
|
||||
<tr><td><b>coinsbolhov.ru</b></td><td>~37 000</td><td>Российская империя, СССР, иностранные монеты. Хорошие цены.</td></tr>
|
||||
<tr><td><b>numizmat.ru</b></td><td>~9 000</td><td>Премиальный сегмент: Proof, золото, крупные номиналы.</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p>Суммарный охват — <b>более 108 000 уникальных монет</b>. Фиды обновляются ежедневно, система фиксирует появление новых позиций, изменения цен и исчезновение монет (вероятные продажи).</p>
|
||||
|
||||
<h2>Как работает система</h2>
|
||||
|
||||
<div class="step"><span class="number">1</span><b>Сканирование фидов.</b> Каждый день (или по кнопке) система загружает XML-каталоги всех трёх магазинов. Из каждого товара извлекаются: название, цена, старая цена, ссылка, изображение, наличие. Для надёжности используется дисковый кеш — если магазин не отвечает, берутся данные предыдущего скана.</div>
|
||||
|
||||
<div class="step"><span class="number">2</span><b>Парсинг деталей.</b> Из фида и со страниц товаров извлекаются характеристики: грейд (сохранность), материал, вес, диаметр, год чеканки, страна. Для монет, где фид не даёт деталей, система автоматически заходит на страницу товара и парсит таблицу характеристик.</div>
|
||||
|
||||
<div class="step"><span class="number">3</span><b>Обогащение данных.</b> Если магазин не указал материал или страну, система определяет их из названия: «2 копейки 1909 года СПБ» → Россия, Медь. Год извлекается с валидацией (не путая каталожные номера с датами).</div>
|
||||
|
||||
<div class="step"><span class="number">4</span><b>Скоринг.</b> Каждая монета получает числовую оценку от 0 до 100 баллов по 8 критериям (подробнее — в следующем разделе). Монеты сортируются по скору: чем выше — тем интереснее.</div>
|
||||
|
||||
<div class="step"><span class="number">5</span><b>Кросс-магазинное сравнение.</b> Система находит одинаковые монеты в разных магазинах (совпадение по названию, грейду и материалу) и показывает разницу в цене. Это позволяет купить монету там, где она дешевле.</div>
|
||||
|
||||
<div class="step"><span class="number">6</span><b>Отслеживание динамики.</b> При каждом скане фиксируется цена каждой монеты. Со временем накапливается история: можно увидеть, когда магазин снизил цену, и купить на просадке.</div>
|
||||
|
||||
<h2 class="page-break">Скоринг: 8 критериев оценки</h2>
|
||||
|
||||
<p>Система оценки основана на анализе 40+ профессиональных источников по нумизматике: PCGS, NGC, Forbes.ru, numisdon.com, CoinWeek, Raritetus и др.</p>
|
||||
|
||||
<div class="no-break">
|
||||
<h3>1. Сохранность / Грейд (до 30 баллов)</h3>
|
||||
<p>Главный фактор стоимости монеты. Каждый шаг грейда может увеличить цену в 2–50 раз. Proof = 28, UNC = 25, AU = 20, XF = 15, VF = 8 баллов. Бонус за исключительную сохранность для возраста: VF+ для монеты до 1800 года — это редкость.</p>
|
||||
</div>
|
||||
|
||||
<div class="no-break">
|
||||
<h3>2. Материал и стоимость металла (до 25 баллов)</h3>
|
||||
<p>Драгоценный металл создаёт «пол» стоимости — монета не может стоить дешевле содержащегося в ней металла. Золото = 22, серебро = 14 баллов. Если монета стоит дешевле стоимости серебра внутри неё (melt value) — это +10 дополнительных баллов. Цена серебра обновляется ежедневно с сайта ЦБ РФ.</p>
|
||||
</div>
|
||||
|
||||
<div class="no-break">
|
||||
<h3>3. Возраст (до 20 баллов)</h3>
|
||||
<p>Чем старше — тем меньше сохранившихся экземпляров. До н.э. = 20, 500+ лет = 18, 300+ = 14, 200+ = 10, 100+ = 6 баллов. Античные монеты показывают 8–15% годового роста.</p>
|
||||
</div>
|
||||
|
||||
<div class="no-break">
|
||||
<h3>4. Российские премиум-периоды (до 15 баллов)</h3>
|
||||
<p>Отдельные периоды русской нумизматики обладают повышенным потенциалом: монеты 1947 и 1958 годов (не поступили в обращение), Смутное время (1610–1612), раннее советское серебро (1921–1931), монеты Николая II, Петра I, Екатерины II.</p>
|
||||
</div>
|
||||
|
||||
<div class="no-break">
|
||||
<h3>5. Мировые монеты (до 10 баллов)</h3>
|
||||
<p>Бонусы за перспективные направления мировой нумизматики: Древняя Греция, Рим, Византия, Боспорское царство, Османская империя, талеры, панды, соверены.</p>
|
||||
</div>
|
||||
|
||||
<div class="no-break">
|
||||
<h3>6. Ошибки чеканки и разновидности (до 15 баллов)</h3>
|
||||
<p>Монеты с браком — отдельная ценная категория. Мул / двойной аверс (+15), брак чеканки (+12), перечекан (+10), серия ЧЯП (+10), отсутствие знака монетного двора (+8). Система автоматически распознаёт браки по названию.</p>
|
||||
</div>
|
||||
|
||||
<div class="no-break">
|
||||
<h3>7. Ценовая эффективность (до 12 баллов)</h3>
|
||||
<p>Бонус за выгодную цену: скидка ≥30% от старой цены (+8), AU+ дешевле 500₽ (+6), UNC до 1000₽ (+4). Чем дешевле монета хорошей сохранности — тем больше бонус.</p>
|
||||
</div>
|
||||
|
||||
<div class="no-break">
|
||||
<h3>8. Штрафы (до −20 баллов)</h3>
|
||||
<p>Снижение скора за негативные факторы: копии (−20), массовые юбилейные СССР (−12), чищеные монеты (−10), современные памятные ЦБ без драгмета (−8). Система фильтрует не-монеты: облигации, марки, аксессуары.</p>
|
||||
</div>
|
||||
|
||||
<h2 class="page-break">Ключевые механики поиска выгоды</h2>
|
||||
|
||||
<h3>Механика 1: Арбитраж между магазинами</h3>
|
||||
|
||||
<p>Одна и та же монета продаётся в разных магазинах по существенно разным ценам. Coin Scout сравнивает цены только для монет с <b>одинаковым грейдом и материалом</b> — чтобы исключить ложные совпадения.</p>
|
||||
|
||||
<div class="box">
|
||||
<b>Реальный пример:</b> «2 копейки 1909 года СПБ», медь, VF.<br>
|
||||
numizm.at — 5 280₽. coinsbolhov.ru — 265₽.<br>
|
||||
Разница: <span class="orange">×20</span>. Одна и та же монета, одинаковый грейд, одинаковый материал, одинаковый тираж.
|
||||
</div>
|
||||
|
||||
<p>Причины ценовых расхождений: разные методы ценообразования, разная оборачиваемость, разные целевые аудитории магазинов. Для покупателя это — окно возможности.</p>
|
||||
|
||||
<h3>Механика 2: Монеты дешевле стоимости металла</h3>
|
||||
|
||||
<p>Иногда серебряная монета продаётся дешевле стоимости содержащегося в ней серебра. Система рассчитывает melt value (вес × текущая цена серебра по ЦБ) и находит такие аномалии.</p>
|
||||
|
||||
<div class="box">
|
||||
<b>Пример:</b> Монета весом 2.7г серебра. Серебро по курсу ЦБ: 188₽/г. Стоимость металла: 508₽. Цена монеты: 500₽.<br>
|
||||
Вы покупаете серебро <b>дешевле рынка</b>, а нумизматическую ценность получаете в подарок.
|
||||
</div>
|
||||
|
||||
<h3>Механика 3: Мониторинг снижений цен</h3>
|
||||
|
||||
<p>Система фиксирует историю цен при каждом скане. Когда магазин снижает цену — монета попадает в раздел «Снижения цен» на дашборде. Покупка на просадке — одна из базовых стратегий.</p>
|
||||
|
||||
<h3>Механика 4: Высокий скор при низкой цене</h3>
|
||||
|
||||
<p>Скоринг учитывает все факторы ценности: грейд, металл, возраст, историческую значимость, редкость. Монета со скором 60+ и ценой до 1000₽ — это потенциально недооценённый экземпляр. Система автоматически сортирует по скору и позволяет фильтровать по цене, материалу, стране и магазину.</p>
|
||||
|
||||
<h3>Механика 5: Обнаружение браков</h3>
|
||||
|
||||
<p>Монеты с ошибками чеканки (смещение, раскол штемпеля, двойной удар, перечекан, мул) — отдельная и высоко ценимая категория. Они часто продаются по обычной цене, потому что продавец не осознаёт редкость. Система автоматически распознаёт браки по названию и помечает их оранжевым тегом.</p>
|
||||
|
||||
<h2 class="page-break">Функции панели управления</h2>
|
||||
|
||||
<div class="no-break">
|
||||
<h3>Горячие монеты</h3>
|
||||
<p>Основной экран. Все доступные монеты, отсортированные по скору. Фильтры: максимальная цена, минимальный грейд, материал, страна, магазин, наличие, дедупликация. Для каждой монеты: подробный анализ с разбивкой по факторам, текст для рассылки, история цен.</p>
|
||||
</div>
|
||||
|
||||
<div class="no-break">
|
||||
<h3>Сравнение магазинов</h3>
|
||||
<p>Таблица одинаковых монет в разных магазинах с разницей в цене. Колонки: грейд, материал, скор, цены в обоих магазинах, процент разницы. Сортировка по разнице — самые выгодные арбитражные возможности наверху.</p>
|
||||
</div>
|
||||
|
||||
<div class="no-break">
|
||||
<h3>Дашборд</h3>
|
||||
<p>Общая аналитика: количество монет, динамика за неделю, текущая цена серебра (ЦБ РФ), статистика парсинга, график новых монет по дням, топ-находки недели, снижения и повышения цен, исчезнувшие монеты (вероятные продажи), распределение по материалам и грейдам.</p>
|
||||
</div>
|
||||
|
||||
<div class="no-break">
|
||||
<h3>История цен</h3>
|
||||
<p>Для каждой монеты доступен график изменения цены. Позволяет увидеть тренд: монета дорожает (спрос растёт) или дешевеет (возможность для покупки).</p>
|
||||
</div>
|
||||
|
||||
<div class="no-break">
|
||||
<h3>Автоматизация</h3>
|
||||
<p>Ежедневное сканирование по расписанию (настраиваемый час). Автоматическое обновление цены серебра с ЦБ РФ. Автопарсинг деталей для монет без характеристик. Дисковый кеш фидов для отказоустойчивости.</p>
|
||||
</div>
|
||||
|
||||
<h2>Стратегия использования</h2>
|
||||
|
||||
<div class="step"><span class="number">1</span><b>Ежедневный мониторинг.</b> Открывайте дашборд — смотрите снижения цен и топ-находки недели. Если появилась монета с высоким скором и низкой ценой — это сигнал.</div>
|
||||
|
||||
<div class="step"><span class="number">2</span><b>Арбитраж.</b> Вкладка «Сравнение» — находите монеты, которые в одном магазине стоят значительно дешевле. Проверяйте грейд и фото на сайтах обоих магазинов.</div>
|
||||
|
||||
<div class="step"><span class="number">3</span><b>Серебро ниже melt.</b> Фильтр «Серебро» + сортировка по скору — монеты, у которых стоимость металла близка к цене или превышает её, помечены в анализе.</div>
|
||||
|
||||
<div class="step"><span class="number">4</span><b>Браки и разновидности.</b> Оранжевые теги «Брак», «Мул», «Перечекан» — это монеты, которые могут стоить значительно дороже, чем указано в магазине.</div>
|
||||
|
||||
<div class="step"><span class="number">5</span><b>Диверсификация.</b> Используйте фильтр по стране, чтобы распределить покупки между разными направлениями: Россия, Европа, античность.</div>
|
||||
|
||||
<div class="box box-accent" style="margin-top:30px">
|
||||
<b>Ключевой принцип:</b> покупайте самую редкую монету в лучшем состоянии за минимальную цену. Coin Scout автоматизирует поиск именно таких совпадений среди 108 000+ позиций.
|
||||
</div>
|
||||
|
||||
<h2>Технические параметры</h2>
|
||||
|
||||
<div class="no-break">
|
||||
<table>
|
||||
<tr><th>Параметр</th><th>Значение</th></tr>
|
||||
<tr><td>Охват</td><td>3 магазина, 108 000+ позиций</td></tr>
|
||||
<tr><td>Частота сканирования</td><td>Ежедневно (настраиваемый час) + ручной запуск</td></tr>
|
||||
<tr><td>Время полного скана</td><td>3–5 минут</td></tr>
|
||||
<tr><td>Скоринг</td><td>8 критериев, 0–100 баллов</td></tr>
|
||||
<tr><td>Критерии сравнения</td><td>Название + грейд + материал</td></tr>
|
||||
<tr><td>Цена серебра</td><td>Автоматически с ЦБ РФ, ежедневно</td></tr>
|
||||
<tr><td>Определение стран</td><td>38 паттернов, автоматически из названия</td></tr>
|
||||
<tr><td>Детекция браков</td><td>6 типов: брак, мул, перечекан, разновидность, пробная, новодел</td></tr>
|
||||
<tr><td>База данных</td><td>SQLite, история цен, детали, лог сканов</td></tr>
|
||||
<tr><td>Развёртывание</td><td>Docker, один контейнер, порт 5180</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p class="note" style="margin-top:30px;text-align:center">Coin Scout · Версия апрель 2026<br>Система является аналитическим инструментом. Решение о покупке всегда принимает человек.<br>Всегда проверяйте монету лично или по фото перед покупкой.</p>
|
||||
|
||||
<script>
|
||||
if (location.search.includes('print')) {
|
||||
window.onload = () => window.print()
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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