const express = require('express')
const path = require('path')
const fs = require('fs')
const Database = require('better-sqlite3')
const cron = require('node-cron')
const { parse: parseHTML } = require('node-html-parser')
const { generateCoinEmail, setGradeScore } = require('./coin-writer')
const PORT = 5180
const DB_PATH = path.resolve(__dirname, 'data', 'coins.db')
const app = express()
app.use(express.json())
app.use(express.static(path.resolve(__dirname, 'public')))
// ─── Feed sources ───
const FEEDS = [
{ id: 'AT', name: 'numizm.at', url: 'https://numizm.at/bitrix/catalog_export/mindbox.php', encoding: 'windows-1251' },
{ id: 'KB', name: 'coinsbolhov.ru', url: 'https://coinsbolhov.ru/bitrix/catalog_export/mindbox.php' },
{ id: 'RU', name: 'numizmat.ru', url: 'https://numizmat.ru/bitrix/catalog_export/mindbox.php', encoding: 'windows-1251' },
]
// ─── Database ───
function initDB() {
const dir = path.dirname(DB_PATH)
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
const db = new Database(DB_PATH)
db.pragma('journal_mode = WAL')
db.exec(`
CREATE TABLE IF NOT EXISTS coins (
id TEXT PRIMARY KEY,
feed TEXT NOT NULL,
name TEXT NOT NULL,
price REAL,
old_price REAL,
url TEXT,
image TEXT,
category TEXT,
first_seen TEXT DEFAULT (datetime('now')),
last_seen TEXT DEFAULT (datetime('now')),
available INTEGER DEFAULT 1
);
CREATE TABLE IF NOT EXISTS coin_details (
coin_id TEXT PRIMARY KEY,
grade TEXT,
material TEXT,
weight TEXT,
diameter TEXT,
year_from INTEGER,
year_to INTEGER,
country TEXT,
in_stock INTEGER DEFAULT 1,
parsed_at TEXT,
FOREIGN KEY (coin_id) REFERENCES coins(id)
);
CREATE TABLE IF NOT EXISTS scan_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at TEXT DEFAULT (datetime('now')),
finished_at TEXT,
new_coins INTEGER DEFAULT 0,
removed_coins INTEGER DEFAULT 0,
details_parsed INTEGER DEFAULT 0,
status TEXT DEFAULT 'running'
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
);
CREATE TABLE IF NOT EXISTS price_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
coin_id TEXT NOT NULL,
price REAL,
recorded_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (coin_id) REFERENCES coins(id)
);
CREATE INDEX IF NOT EXISTS idx_ph_coin ON price_history(coin_id);
CREATE INDEX IF NOT EXISTS idx_ph_date ON price_history(recorded_at);
`)
// Default settings
const defaults = {
max_price: '3000',
min_grade: 'VF',
preferred_material: 'серебро,золото',
auto_scan_hour: '8',
scan_enabled: '1',
hide_dupes: '1',
}
const upsert = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)')
for (const [k, v] of Object.entries(defaults)) upsert.run(k, v)
return db
}
const db = initDB()
// ─── Grade scoring ───
const GRADE_ORDER = ['G', 'AG', 'VG', 'F', 'VF', 'XF', 'EF', 'AU', 'UNC', 'BU', 'Proof']
function gradeScore(grade) {
if (!grade) return -1
const g = grade.toUpperCase().replace(/[\s\-\+\.]/g, '')
for (let i = GRADE_ORDER.length - 1; i >= 0; i--) {
if (g.includes(GRADE_ORDER[i].toUpperCase())) return i
}
return -1
}
function gradeAtLeast(grade, minGrade) {
return gradeScore(grade) >= gradeScore(minGrade)
}
// Inject gradeScore into coin-writer
setGradeScore(gradeScore)
// ─── Feed file cache (disk) ───
const FEED_CACHE_DIR = path.resolve(__dirname, 'data', 'feed-cache')
if (!fs.existsSync(FEED_CACHE_DIR)) fs.mkdirSync(FEED_CACHE_DIR, { recursive: true })
function saveFeedCache(feedId, items) {
const file = path.resolve(FEED_CACHE_DIR, `${feedId}.json`)
fs.writeFileSync(file, JSON.stringify(items))
}
function loadFeedCache(feedId) {
const file = path.resolve(FEED_CACHE_DIR, `${feedId}.json`)
if (!fs.existsSync(file)) return null
try { return JSON.parse(fs.readFileSync(file, 'utf-8')) } catch { return null }
}
// ─── Feed parsing (Mindbox YML) ───
async function fetchFeed(feedCfg) {
console.log(`[feed] Fetching ${feedCfg.name}...`)
const res = await fetch(feedCfg.url, {
signal: AbortSignal.timeout(120000),
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; CoinScout/1.0)' },
})
if (!res.ok) throw new Error(`Feed ${feedCfg.name}: HTTP ${res.status}`)
// Stream parse to avoid memory issues with huge feeds (100MB+)
const items = []
const reader = res.body.getReader()
const decoder = new TextDecoder(feedCfg.encoding || 'utf-8')
let buffer = ''
let totalBytes = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
totalBytes += value.length
buffer += decoder.decode(value, { stream: true })
// Extract complete ... blocks from buffer
let startIdx
while ((startIdx = buffer.indexOf('', startIdx)
if (endIdx === -1) break // incomplete, wait for more data
const offerStr = buffer.substring(startIdx + 7, endIdx) // after ""
const tagEnd = offerStr.indexOf('>')
if (tagEnd === -1) continue
const attrs = offerStr.substring(0, tagEnd)
const idMatch = attrs.match(/id="([^"]*)"/)
const availMatch = attrs.match(/available="([^"]*)"/)
if (!idMatch) continue
const id = idMatch[1]
const available = availMatch ? availMatch[1] : 'true'
const body = offerStr.substring(tagEnd + 1)
const get = (tag) => {
const s = body.indexOf(`<${tag}`)
if (s === -1) return ''
const cs = body.indexOf('>', s) + 1
const e = body.indexOf(`${tag}>`, cs)
if (e === -1) return ''
return body.substring(cs, e).trim()
}
const price = parseFloat(get('price')) || 0
const oldPrice = parseFloat(get('oldprice')) || 0
const name = get('name').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
const url = get('url')
const image = get('picture')
const category = get('categoryId')
// Extra fields from feed (may exist)
const grade = get('condition') || ''
const material = get('material') || ''
const weight = get('weight') || get('Weight') || ''
const diameter = get('dia') || ''
const year = get('year') || ''
items.push({
id: `${feedCfg.id}_${id}`,
feed: feedCfg.id,
name,
price,
old_price: oldPrice,
url,
image,
category,
available: available === 'true' ? 1 : 0,
grade, material, weight, diameter, year,
})
}
// Keep only last incomplete chunk (trim everything before last possible 100000) {
const lastOffer = buffer.lastIndexOf(' 0) buffer = buffer.substring(lastOffer)
}
}
console.log(`[feed] ${feedCfg.name}: ${(totalBytes / 1024).toFixed(0)} KB, ${items.length} items`)
return items
}
// ─── Product page parsing ───
async function parseProductPage(url) {
try {
const res = await fetch(url, {
signal: AbortSignal.timeout(15000),
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; CoinScout/1.0)' },
})
if (!res.ok) return null
const html = await res.text()
const root = parseHTML(html)
const result = { grade: '', material: '', weight: '', diameter: '', year_from: null, year_to: null, country: '', in_stock: 0 }
// In stock check
const bodyText = root.text || ''
if (/в наличии|в\s*корзину|купить|add.to.cart/i.test(bodyText) && !/нет в наличии/i.test(bodyText)) {
result.in_stock = 1
}
if (/нет в наличии|sold.out|отсутствует/i.test(bodyText)) {
result.in_stock = 0
}
// Parse characteristics table
const rows = root.querySelectorAll('tr, .char-item, .properties__item, [class*="prop"]')
for (const row of rows) {
const text = row.text.toLowerCase()
const val = row.querySelectorAll('td, span, div')
const rawVal = val.length >= 2 ? val[val.length - 1].text.trim() : row.text.trim()
if (/сохранность|состояние|grade|грейд/.test(text)) {
result.grade = rawVal.replace(/\s+/g, ' ').trim()
}
if (/материал|металл|metal/.test(text)) {
result.material = rawVal
}
if (/вес|масса|weight/.test(text)) {
result.weight = rawVal
}
if (/диаметр|diameter/.test(text)) {
result.diameter = rawVal
}
if (/страна|country/.test(text)) {
result.country = rawVal
}
}
// Try to extract grade from page if not found in table
if (!result.grade) {
const gradeMatch = bodyText.match(/(?:сохранность|состояние|grade)[:\s]*([A-Za-z\+\-\/]+)/i)
if (gradeMatch) result.grade = gradeMatch[1].trim()
}
// Try extract years from name/URL
const yearMatch = (url + ' ' + bodyText).match(/(\d{3,4})\s*[-–]\s*(\d{3,4})\s*(?:год|г\.|г\b|до н)/i)
if (yearMatch) {
result.year_from = parseInt(yearMatch[1])
result.year_to = parseInt(yearMatch[2])
} else {
const singleYear = url.match(/(\d{4})_goda/)
if (singleYear) {
result.year_from = parseInt(singleYear[1])
result.year_to = result.year_from
}
}
return result
} catch (e) {
console.error(`[parse] Error parsing ${url}: ${e.message}`)
return null
}
}
// ─── Guess material from coin name ───
function guessMaterial(name) {
const n = name.toLowerCase()
if (/золот|gold|\bgold\b/.test(n)) return 'Золото'
if (/серебр|silver|\bsilver\b|биллон|billon/.test(n)) return 'Серебро'
if (/медно-?никел|мельхиор|cupronickel/i.test(n)) return 'Медно-никель'
if (/медь|copper|\bcopper\b|бронз|bronze/.test(n)) return 'Медь'
return ''
}
// ─── Build email newsletter copy (2-3 paragraphs) ───
function buildEmailCopy(coin, details, score) {
const name = coin.name || ''
const mat = (details.material || '').toLowerCase()
const year = details.year_from || 0
const age = year ? 2026 - year : 0
const price = coin.price || 0
const grade = details.grade || ''
const gs = gradeScore(grade)
const weightG = parseFloat(details.weight) || 0
const isSilver = /серебро|silver/.test(mat)
const isGold = /золото|gold/.test(mat)
const isPrecious = isSilver || isGold
const metalName = isGold ? 'золото' : isSilver ? 'серебро' : (details.material || 'металл')
// Paragraph 1 — Hook: what is this coin, why it's special
const hooks = []
if (year && year < 0) {
hooks.push(`${name} — подлинный свидетель эпохи, которой более ${Math.abs(year) + 2026} лет. Эта монета существовала задолго до падения Рима и рождения большинства европейских государств. Держать в руках предмет такой древности — ощущение, которое невозможно описать словами.`)
} else if (age > 400) {
hooks.push(`${name} — ${age} лет истории в вашей ладони. Эта монета была отчеканена в эпоху, когда мир выглядел совершенно иначе. Немногие предметы способны так осязаемо связать нас с далёким прошлым.`)
} else if (age > 200) {
hooks.push(`${name} — монета, пережившая больше двух столетий. Она видела смену эпох, войн и империй, и дошла до наших дней${gs >= gradeScore('VF') ? ' в прекрасном состоянии' : ''}. Настоящий осколок ушедшей эпохи.`)
} else if (age > 100) {
hooks.push(`${name} — больше века истории. Монета, отчеканенная в начале прошлого столетия, когда мир стоял на пороге перемен.${isPrecious ? ` ${metalName === 'золото' ? 'Золотая' : 'Серебряная'} — а значит, ценна и как металл, и как предмет коллекционирования.` : ''}`)
} else {
hooks.push(`${name}${isPrecious ? ` — ${metalName === 'золото' ? 'золотая' : 'серебряная'} монета` : ''}${gs >= gradeScore('UNC') ? ' в идеальной сохранности' : gs >= gradeScore('AU') ? ' в отличном состоянии' : ''}.${year ? ` ${year} год.` : ''}`)
}
// Add period flavor
if (/николай\s*ii|николая\s*ii/i.test(name)) {
hooks.push('Последний российский император, чьё правление стало одним из самых трагических и притягательных периодов русской истории. Монеты Николая II — неизменный фаворит коллекционеров.')
} else if (/пётр|петр\s*i|петра\s*i/i.test(name)) {
hooks.push('Монета эпохи Петра Великого — царя-реформатора, изменившего облик России навсегда. Коллекционный спрос на монеты этого периода стабильно растёт.')
} else if (/екатерин/i.test(name)) {
hooks.push('Эпоха Екатерины Великой — золотой век Российской империи. Монеты этого периода сочетают историческую значимость с коллекционной привлекательностью.')
} else if (/александр\s*ii/i.test(name)) {
hooks.push('Царь-Освободитель, отменивший крепостное право. Интерес коллекционеров к монетам Александра II стабильно растёт.')
} else if (/финлянд/i.test(name)) {
hooks.push('Монеты Русской Финляндии — особая глава в истории нумизматики. Отдельная серия с ограниченным ареалом обращения всегда привлекает серьёзных коллекционеров.')
} else if (/римск|roman|денарий|антониниан/i.test(name)) {
hooks.push('Монета Римской империи — цивилизации, заложившей фундамент западного мира. Глобальный рынок античных монет растёт на 8-12% ежегодно.')
} else if (/греч|greek|драхм|обол|тетра/i.test(name)) {
hooks.push('Монета Древней Греции — колыбели демократии и философии. Рынок греческих античных монет показывает рост до 15% в год.')
} else if (/осман|ottoman/i.test(name)) {
hooks.push('Монета Османской империи — одного из величайших государств в истории. Исламская нумизматика — растущий и пока недооценённый сегмент рынка.')
} else if (/сефевид|safavid/i.test(name)) {
hooks.push('Монета Сефевидской Персии — империи шахов, объединившей Иран. Редкий исламский регион, интерес к которому среди коллекционеров неуклонно растёт.')
} else if (/боспор|пантикапей/i.test(name)) {
hooks.push('Монета Боспорского царства — античного государства на берегах Чёрного моря. Крымская античность — уникальная ниша с растущим спросом.')
} else if (/визант|byzant/i.test(name)) {
hooks.push('Монета Византийской империи — тысячелетнего наследника Рима. Византийская нумизматика привлекает всё больше коллекционеров.')
} else if (/япон|japan/i.test(name)) {
hooks.push('Монета Японии — страны с богатейшей нумизматической традицией. Азиатский рынок коллекционных монет растёт на 7% в год.')
} else if (/1921|1922|1923|1924|1925|1926|1927|1928|1929|1930|1931/.test(name) && /серебро|silver/.test(mat)) {
hooks.push('Серебряная монета раннего СССР — периода, когда серебро ещё использовалось в обращении. Тиражи были ограничены, а большая часть была изъята и переплавлена.')
}
// Paragraph 2 — Value proposition: grade, material, melt value
const value = []
if (gs >= gradeScore('UNC')) {
value.push(`Сохранность ${grade} — монета никогда не была в обращении. Все детали рельефа идеальны, присутствует оригинальный блеск. Это высшая категория для коллекционера.`)
} else if (gs >= gradeScore('AU')) {
value.push(`Сохранность ${grade} — почти идеальное состояние с минимальными следами обращения. Такой грейд${age > 100 ? ` для монеты возрастом ${age} лет` : ''} встречается нечасто.`)
} else if (gs >= gradeScore('XF')) {
value.push(`Сохранность ${grade} — высокое качество${age > 100 ? ` для монеты ${age}-летней давности` : ''}. Все детали чёткие и хорошо различимы.`)
} else if (gs >= gradeScore('VF') && age > 200) {
value.push(`Сохранность ${grade} — достойное состояние для монеты возрастом ${age} лет. Большинство монет этой эпохи дошли до нас в значительно худшем виде.`)
}
if (isSilver && weightG > 0 && price > 0) {
const melt = Math.round(weightG * SILVER_PRICE_PER_GRAM)
const ratio = Math.round(melt / price * 100)
if (ratio > 100) {
value.push(`Содержит ${weightG}г серебра стоимостью ~${melt}₽ — при цене монеты всего ${price}₽. Вы покупаете серебро дешевле его рыночной стоимости, а нумизматическую ценность получаете бонусом.`)
} else if (ratio > 60) {
value.push(`${weightG}г серебра обеспечивают ${ratio}% цены. Драгоценный металл создаёт надёжный «пол» стоимости — даже при колебаниях нумизматического рынка.`)
}
} else if (isGold) {
value.push(`Золотая монета — актив, ценность которого проверена тысячелетиями. Золото защищает от инфляции и обеспечивает стабильный рост.`)
}
if (coin.old_price && coin.old_price > price) {
const disc = Math.round((coin.old_price - price) / coin.old_price * 100)
if (disc >= 15) {
value.push(`Сейчас доступна со скидкой ${disc}% от прежней цены ${coin.old_price}₽.`)
}
}
// Paragraph 3 — Call to action / scarcity
const cta = []
if (age > 200) {
cta.push(`С каждым годом таких монет становится всё меньше — утраты, повреждения и оседание в частных коллекциях делают каждый доступный экземпляр ценнее предыдущего.`)
}
if (isPrecious && gs >= gradeScore('VF')) {
cta.push(`${metalName === 'золото' ? 'Золото' : 'Серебро'} в сочетании с хорошей сохранностью — формула, которая работает на протяжении всей истории нумизматики.`)
}
if (price <= 1000 && score >= 40) {
cta.push(`При цене ${price}₽ это одна из самых доступных монет с высоким инвестиционным потенциалом в нашем каталоге.`)
} else if (price <= 3000 && score >= 50) {
cta.push(`${price}₽ за монету с таким набором характеристик — привлекательная цена.`)
}
cta.push(`Цена: ${price}₽`)
return [hooks.join(' '), value.join(' '), cta.join(' ')].filter(p => p.trim()).join('\n\n')
}
// ─── Build human-readable investment summary (short + detailed) ───
function buildSummary(coin, details, score, reasons) {
const short = []
const full = []
const mat = (details.material || '').toLowerCase()
const year = details.year_from || 0
const age = year ? 2026 - year : 0
const price = coin.price || 0
const gs = gradeScore(details.grade)
const weightG = parseFloat(details.weight) || 0
const grade = details.grade || '?'
// ── Investment tier ──
if (score >= 60) { short.push('Топ-находка.'); full.push('🏆 Инвестиционный рейтинг: ОТЛИЧНЫЙ. Эта монета набрала один из высших баллов в нашей системе оценки, основанной на 40+ профессиональных источников по нумизматике.') }
else if (score >= 45) { short.push('Сильная монета.'); full.push('⭐ Инвестиционный рейтинг: ВЫСОКИЙ. Монета показывает сильные результаты по нескольким ключевым критериям оценки.') }
else if (score >= 30) { short.push('Хороший вариант.'); full.push('✅ Инвестиционный рейтинг: ХОРОШИЙ. Монета соответствует основным критериям инвестиционной привлекательности.') }
else { full.push('📊 Инвестиционный рейтинг: СРЕДНИЙ. Монета имеет отдельные привлекательные характеристики.') }
// ── Grade analysis ──
full.push('')
full.push('📋 СОХРАННОСТЬ')
if (gs >= gradeScore('Proof')) {
short.push('Proof — высшее качество чеканки.')
full.push(`Грейд: ${grade} (Proof) — монета специального чекана с зеркальной поверхностью и матовым рельефом. Это высшая категория качества, которая всегда ценится коллекционерами. Proof-монеты чеканятся полированными штемпелями на специально подготовленных заготовках.`)
} else if (gs >= gradeScore('UNC')) {
short.push('UNC — не была в обращении.')
full.push(`Грейд: ${grade} (Uncirculated) — монета никогда не находилась в денежном обращении. Все мельчайшие детали рельефа сохранены, присутствует оригинальный штемпельный блеск. Переход AU→UNC — самый значительный скачок стоимости в нумизматике (данные PCGS).`)
} else if (gs >= gradeScore('AU')) {
short.push(`${grade} — отличное состояние.`)
full.push(`Грейд: ${grade} (About Uncirculated) — минимальные следы обращения видны только на самых выступающих точках рельефа. Сохраняется значительная часть штемпельного блеска. Отличное состояние для коллекционирования и инвестиций.`)
} else if (gs >= gradeScore('XF')) {
short.push(`${grade} — высокая сохранность.`)
full.push(`Грейд: ${grade} (Extremely Fine) — лёгкий износ на выступающих частях рельефа, но все детали чёткие и хорошо читаемые. Хорошее состояние для монет этой ценовой категории.`)
} else if (gs >= gradeScore('VF')) {
full.push(`Грейд: ${grade} (Very Fine) — умеренный износ, все основные элементы дизайна различимы. Для старинных монет VF — вполне приемлемая сохранность.`)
}
if (year && year < 1800 && gs >= gradeScore('VF')) {
full.push(`⚡ Монета ${year} года в состоянии ${grade} — это исключительная сохранность! Большинство монет этого периода дошли до нас в состоянии F и ниже. Хорошо сохранившиеся экземпляры составляют менее 5-15% от всех выпущенных (оценка по данным PCGS).`)
} else if (year && year < 1900 && gs >= gradeScore('XF')) {
full.push(`Для монеты XIX века грейд ${grade} — это высокая сохранность, такие экземпляры встречаются нечасто.`)
}
// ── Material & melt value ──
full.push('')
full.push('💰 МАТЕРИАЛ И СТОИМОСТЬ МЕТАЛЛА')
if (/золото|gold/.test(mat)) {
short.push('Золото — надёжный актив.')
full.push(`Материал: золото. Золотые монеты — наиболее надёжный нумизматический актив. Цена золотых монет практически никогда не падает ниже стоимости содержащегося в них металла (melt value floor). Средняя доходность золотых инвестиционных монет: 10.9% годовых за 26 лет (American Gold Eagle, 2000-2026).`)
} else if (/серебро|silver/.test(mat)) {
if (weightG > 0 && price > 0) {
const melt = Math.round(weightG * SILVER_PRICE_PER_GRAM)
const ratio = Math.round(melt / price * 100)
if (ratio > 100) {
short.push(`Дешевле стоимости серебра (${melt}₽)!`)
full.push(`Материал: серебро (${weightG}г). Стоимость серебра в монете: ~${melt}₽ (по курсу ЦБ РФ ~${SILVER_PRICE_PER_GRAM}₽/г). При цене ${price}₽ монета стоит ДЕШЕВЛЕ содержащегося в ней металла! Это означает практически безрисковую покупку — даже без нумизматической ценности вы можете получить прибыль просто на стоимости серебра.`)
} else if (ratio > 60) {
short.push(`${ratio}% цены = серебро.`)
full.push(`Материал: серебро (${weightG}г). Стоимость серебра: ~${melt}₽ (${ratio}% от цены монеты). Высокая обеспеченность драгоценным металлом создаёт «пол» стоимости — монета вряд ли подешевеет ниже ${melt}₽ даже при падении нумизматического спроса.`)
} else {
full.push(`Материал: серебро (${weightG}г). Стоимость металла: ~${melt}₽ (${ratio}% от цены). Основная ценность — нумизматическая, но серебро создаёт базовую поддержку цены.`)
}
} else {
full.push(`Материал: серебро. Серебряные монеты — классический объект нумизматических инвестиций. Серебро создаёт «пол» стоимости и обеспечивает ликвидность.`)
}
} else if (/биллон|billon/.test(mat)) {
full.push(`Материал: биллон (сплав с содержанием серебра). Исторически биллонные монеты чеканились в периоды экономических трудностей, что придаёт им дополнительную историческую ценность.`)
} else if (mat) {
full.push(`Материал: ${details.material || mat}. Монеты из недрагоценных металлов ценятся прежде всего за редкость, историческую значимость и состояние.`)
}
// ── Historical context ──
if (year) {
full.push('')
full.push('📜 ИСТОРИЧЕСКАЯ ЗНАЧИМОСТЬ')
if (year < 0) {
full.push(`Дата: ${Math.abs(year)} г. до н.э. Монете более ${Math.abs(year) + 2026} лет — это подлинный античный артефакт. Рынок античных монет показывает стабильный рост 8-15% годовых (данные numisdon.com). Греческие городские монеты (Афины, Коринф, Сиракузы) выросли на ~15% в год с 2020 года.`)
short.push('Античный артефакт.')
} else if (age > 300) {
full.push(`Дата: ${year} г. (${age} лет). Монетам этого периода свойственно устойчивое удорожание: предложение на рынке сокращается каждый год (утрата, повреждения, оседание в коллекциях), а спрос растёт. По данным Forbes.ru, царские монеты растут на 10-15% ежегодно.`)
short.push(`${age} лет — предложение сокращается.`)
} else if (age > 100) {
full.push(`Дата: ${year} г. (${age} лет). Монеты старше 100 лет представляют интерес как исторические артефакты и объекты инвестиций. Предложение на рынке постепенно сокращается.`)
}
}
// ── Price analysis ──
full.push('')
full.push('💲 АНАЛИЗ ЦЕНЫ')
if (coin.old_price && coin.old_price > price) {
const disc = Math.round((coin.old_price - price) / coin.old_price * 100)
if (disc >= 15) {
short.push(`Скидка ${disc}%.`)
full.push(`Текущая цена: ${price}₽ (была ${coin.old_price}₽, скидка ${disc}%). Снижение цены при сохранении всех качественных характеристик — хорошая точка входа.`)
} else {
full.push(`Текущая цена: ${price}₽ (была ${coin.old_price}₽).`)
}
} else {
full.push(`Текущая цена: ${price}₽.`)
}
if (score >= 50 && price <= 1000) {
full.push('Низкая цена при высоком скоринге — потенциально недооценённая монета.')
}
if (gs >= gradeScore('AU') && price <= 500) {
full.push(`${grade} за ${price}₽ — значительно ниже типичной рыночной стоимости для монет такого грейда.`)
}
// ── Unique context from reasons ──
const uniqueReasons = reasons.filter(r =>
!r.startsWith('Proof') && !r.startsWith('UNC') && !r.startsWith('AU') && !r.startsWith('XF') && !r.startsWith('VF') &&
!r.startsWith('Золото') && !r.startsWith('Серебро') && !r.startsWith('Биллон') &&
!/г\. —/.test(r) && !/стоимости/.test(r)
)
if (uniqueReasons.length) {
full.push('')
full.push('🔍 ОСОБЕННОСТИ')
uniqueReasons.forEach(r => full.push(`• ${r}`))
}
// ── Recommendation ──
full.push('')
full.push('📈 РЕКОМЕНДАЦИЯ')
if (score >= 60) {
full.push(`«${coin.name}» — одна из лучших находок в текущем каталоге. Рекомендуется к покупке при наличии бюджета.`)
} else if (score >= 45) {
full.push(`«${coin.name}» заслуживает внимания. Хорошее соотношение цена/качество для долгосрочного портфеля.`)
} else {
full.push(`Монета может быть интересна как часть диверсифицированного портфеля.`)
}
if (/серебро|silver|золото|gold/.test(mat) && gs >= gradeScore('VF')) {
full.push('Драгметалл + хорошая сохранность = надёжная комбинация на горизонте 5-10 лет. Средняя доходность: 4.8-14% годовых (PCGS, Penn State study).')
}
full.push(`Рекомендуемый срок владения: 7-10 лет. Учитывайте дилерскую наценку 10-30%.`)
// ── Email copy (2-3 paragraphs for newsletters) ──
const email = generateCoinEmail(coin, details, score)
return { short: short.join(' '), full: full.join('\n'), email }
}
// ─── Check if item is actually a coin ───
function isCoin(name) {
const n = name.toLowerCase()
// Exclude non-coins
if (/открытк|альбом|капсул|лист[ыа]\b|подставк|футляр|лупа|пинцет|рамк|планшет|холдер|книг/.test(n)) return false
return true
}
// ─── Investment score + reasons ───
// Based on research from 12+ professional numismatic sources:
// numizmatik.ru, Forbes.ru, kp.ru, thecoinsexplorer.com, trustedcoins.com,
// NGC, PCGS guides, coinweek.com, numisdon.com, zolotoy-zapas.ru, etc.
// Silver price ~200 RUB/gram (ЦБ РФ, April 2026)
const SILVER_PRICE_PER_GRAM = 200
function investmentScore(coin, details) {
let score = 0
const reasons = []
const name = (coin.name || '').toLowerCase()
const mat = (details.material || '').toLowerCase()
const gs = gradeScore(details.grade)
const year = details.year_from || 0
const age = year ? 2026 - year : 0
const price = coin.price || 0
// ══════════════════════════════════
// 1. GRADE (max ~30 pts)
// Grading impact: каждый шаг грейда может x2-x50 цену
// AU→UNC — самый большой скачок стоимости
// ══════════════════════════════════
if (gs >= gradeScore('Proof')) { score += 28; reasons.push('Proof — зеркальная поверхность') }
else if (gs >= gradeScore('UNC')) { score += 25; reasons.push('UNC — не была в обращении') }
else if (gs >= gradeScore('AU')) { score += 20; reasons.push('AU — почти без следов обращения') }
else if (gs >= gradeScore('XF')) { score += 15; reasons.push('XF — лёгкий износ') }
else if (gs >= gradeScore('VF')) { score += 8 }
// Pre-1900 в хорошем грейде — особенно ценно
if (year && year < 1800 && gs >= gradeScore('VF')) {
score += 8; reasons.push(`${year} г. в VF+ — исключительная сохранность для возраста`)
} else if (year && year < 1900 && gs >= gradeScore('XF')) {
score += 5; reasons.push(`XF для XIX века — высокая сохранность`)
}
// ══════════════════════════════════
// 2. MATERIAL & MELT VALUE (max ~25 pts)
// Серебро/золото = «пол» стоимости (melt value floor)
// Монета не может стоить дешевле металла
// ══════════════════════════════════
if (/золото|gold/.test(mat)) {
score += 22; reasons.push('Золото — надёжный актив')
} else if (/серебро|silver/.test(mat)) {
score += 14; reasons.push('Серебро')
const weightG = parseFloat(details.weight) || 0
if (weightG > 0 && price > 0) {
const meltApprox = weightG * SILVER_PRICE_PER_GRAM
const meltRatio = meltApprox / price
if (meltRatio > 1.0) { score += 10; reasons.push(`Дешевле стоимости серебра (${Math.round(meltApprox)}₽ металл)!`) }
else if (meltRatio > 0.7) { score += 5; reasons.push(`Цена близка к стоимости металла (${Math.round(meltRatio * 100)}%)`) }
}
} else if (/биллон|billon/.test(mat)) {
score += 6; reasons.push('Биллон (сплав с серебром)')
}
// ══════════════════════════════════
// 3. AGE & HISTORICAL SIGNIFICANCE (max ~20 pts)
// Античные монеты: 8-15% годового роста (numisdon.com)
// Чем старше + лучше сохранность = экспоненциально ценнее
// ══════════════════════════════════
if (year && year < 0) { score += 20; reasons.push(`${Math.abs(year)} г. до н.э. — античная монета`) }
else if (age > 500) { score += 18; reasons.push(`${year} г. — старше 500 лет`) }
else if (age > 300) { score += 14; reasons.push(`${year} г. — старше 300 лет`) }
else if (age > 200) { score += 10; reasons.push(`${year} г. — старше 200 лет`) }
else if (age > 100) { score += 6; reasons.push(`${year} г. — старше 100 лет`) }
else if (age > 50) { score += 2 }
// ══════════════════════════════════
// 4. RUSSIAN PREMIUM PERIODS (max ~12 pts)
// Forbes.ru: царские монеты растут 10-15% в год
// zolotoy-zapas.ru: Николай II — самые востребованные
// numizmatik.ru: Смутное время — редчайший период
// ══════════════════════════════════
// Николай II (1894-1917) — культовый, всегда в спросе
if (year >= 1894 && year <= 1917 && /николай|nikolay/.test(name)) {
score += 6; reasons.push('Николай II — всегда востребован коллекционерами')
}
// Александр II (1855-1881) — растущий спрос
if (year >= 1855 && year <= 1881 && /александр|aleksandr|alexandr/.test(name)) {
score += 5; reasons.push('Александр II — растущий спрос')
}
// Александр III (1881-1894)
if (year >= 1881 && year <= 1894 && /александр\s*iii|aleksandr/.test(name)) {
score += 5; reasons.push('Александр III — короткое правление, мало монет')
}
// Пётр I (1682-1725)
if (/пётр|петр|peter|petr/.test(name) && year >= 1682 && year <= 1725) {
score += 6; reasons.push('Пётр I — реформатор, высокий коллекционный спрос')
}
// Смутное время
if (/смутн|1610|1611|1612|владислав|лжедмитрий/.test(name)) {
score += 10; reasons.push('Смутное время — редчайший период')
}
// Медный бунт
if (/медный бунт|1654|1655|1656/.test(name) && /алексей|aleksey/.test(name)) {
score += 6; reasons.push('Медный бунт — историческое событие')
}
// Раннее СССР серебро (1921-1931) — малые тиражи
if (year >= 1921 && year <= 1931 && /серебро|silver/.test(mat)) {
score += 7; reasons.push('Раннее СССР серебро — редкие тиражи')
}
// Русская Финляндия — отдельная серия, коллекционный спрос
if (/финлянд|finland/.test(name) && /серебро|silver/.test(mat)) {
score += 4; reasons.push('Русская Финляндия серебро — узкая серия')
}
// СССР 1947, 1958 — не вышли в обращение, крайне редки
if ((year === 1947 || year === 1958) && /копе|рубл/.test(name)) {
score += 15; reasons.push(`${year} г. — не выпущена в обращение, раритет`)
}
// Пробные монеты
if (/пробн|проба|pattern|trial|essai/.test(name)) {
score += 12; reasons.push('Пробная монета — коллекционная элита')
}
// Перепутки (чужие заготовки)
if (/перепутк|на заготовке|wrong planchet|на чужом/.test(name)) {
score += 10; reasons.push('Перепутка — монета на чужой заготовке')
}
// 1 рубль 1924 — недооценён
if (year === 1924 && /рубл|rubl/.test(name) && /серебро|silver/.test(mat)) {
score += 5; reasons.push('1 рубль 1924 серебро — недооценён относительно редкости')
}
// Георгий Победоносец — самые ликвидные инвестиционные
if (/георгий победоносец|saint george/.test(name)) {
score += 6; reasons.push('Георгий Победоносец — максимальная ликвидность')
}
// Павел I (1796-1801) — короткое правление, редкие монеты
if (/павел|pavel|paul/.test(name) && year >= 1796 && year <= 1801) {
score += 5; reasons.push('Павел I — короткое правление, редкие монеты')
}
// Анна Иоанновна (1730-1740) — инвестиционный потенциал
if (/анна.*иоанн|anna.*ioann/.test(name) && year >= 1730 && year <= 1740) {
score += 4; reasons.push('Анна Иоанновна — высокий инвестиционный потенциал')
}
// Елизавета Петровна (1741-1762) — редкие тиражи
if (/елизавет|elizabet/.test(name) && year >= 1741 && year <= 1762) {
score += 4; reasons.push('Елизавета Петровна — редкие тиражи')
}
// Екатерина II — особенно сибирские монеты (содержат серебро и золото в меди)
if (/екатерин|catherine|katarina/.test(name) && year >= 1762 && year <= 1796) {
score += 4; reasons.push('Екатерина II — популярный период')
}
if (/сибирск|suzun|сузун/.test(name)) {
score += 6; reasons.push('Сибирская монета — медь с примесью серебра и золота')
}
// Полтина/полтинник серебро — крупный номинал, ликвиден
if (/полтин|poltina/.test(name) && /серебро|silver/.test(mat)) {
score += 4; reasons.push('Полтина серебро — ликвидный крупный номинал')
}
// Рубль серебро Империя — самый коллекционируемый номинал
if (/\b1\s*рубл|рубль\b/.test(name) && /серебро|silver/.test(mat) && year >= 1700 && year <= 1917) {
score += 5; reasons.push('Серебряный рубль Империи — топ коллекционного спроса')
}
// Набор/серия — завершение серии создаёт давление на цену ключевых дат
if (/ключев.*дат|key date/.test(name)) {
score += 6; reasons.push('Ключевая дата серии — давление коллекционеров')
}
// Rainbow toning — премия до x4 для серебра
if (/rainbow|радуж|тонир|toning|toned/.test(name)) {
score += 3; reasons.push('Тонировка — возможная премия')
}
// ══════════════════════════════════
// 5. WORLD COINS PREMIUMS
// Античные римские/греческие: 8-12% рост в год
// Лунные серии, Панды: сильный рост
// Британские соверены: стабильный спрос
// ══════════════════════════════════
if (/римск|roman|антониниан|денарий|denari|follis/.test(name)) {
score += 5; reasons.push('Римская империя — растущий глобальный рынок')
}
if (/греч|greek|драхм|drachm|обол|obol|тетра|tetra/.test(name)) {
score += 6; reasons.push('Древняя Греция — 15% рост в год')
}
if (/боспор|пантикапей|panticap|bospor/.test(name)) {
score += 5; reasons.push('Боспорское царство — крымская античность')
}
// Византия — золото/серебро доступно, рост 8-12%
if (/визант|byzant|фоллис|follis|солид|solidus|tremissis/.test(name)) {
score += 5; reasons.push('Византия — доступная античная империя')
if (/золото|gold|солид|solidus/.test(name + ' ' + mat)) {
score += 4; reasons.push('Византийское золото — пол стоимости металла')
}
}
if (/соверен|sovereign/.test(name)) {
score += 4; reasons.push('Соверен — стабильный глобальный спрос')
}
// Османские/Сефевидские — недооценены, растущий интерес
if (/осман|ottoman|султан|sultan|пара\b|акче|akce/.test(name)) {
score += 4; reasons.push('Османская империя — недооценённый сегмент')
}
if (/сефевид|safavid|шахи|shahi|аббаси|abbasi/.test(name)) {
score += 5; reasons.push('Сефевиды — редкий исламский регион')
}
// Китайские Панды — сильный рост, особенно ранние
if (/панда|panda/.test(name)) {
score += 5; reasons.push('Панда — растущий азиатский рынок')
}
// Японские монеты — бум азиатской нумизматики
if (/япон|japan|\bмон\s+япон|\d+\s*мон\b|сен\b|иен|yen/.test(name) && age > 100) {
score += 4; reasons.push('Японская монета — растущий рынок Азии')
}
// Талеры — крупное серебро, всегда ликвидны
if (/талер|thaler|taler/.test(name)) {
score += 5; reasons.push('Талер — ликвидное крупное серебро')
}
// Серия ЧЯП (10 руб биметалл) — Чечня, ЯНАО, Пермский край
if (/чеченск|ямало|пермский край/.test(name) && /10 руб/.test(name)) {
score += 10; reasons.push('Серия ЧЯП — малотиражная, высокий спрос')
}
// 1 рубль 1997/1998 ММД широкий кант
if (/широк.*кант|1997.*ммд|1998.*ммд/.test(name) && /рубл/.test(name)) {
score += 8; reasons.push('Широкий кант — редкий производственный вариант')
}
// Лунные серии (Австралия, Великобритания)
if (/lunar|лунн/.test(name)) {
score += 4; reasons.push('Лунная серия — коллекционный рост')
}
// ══════════════════════════════════
// 6. ERROR COINS / VARIETIES (max ~15 pts)
// PCGS: ошибки чеканки — отдельная ценная категория
// Перепутки штемпелей, двойной удар, смещение
// ══════════════════════════════════
if (/ошибк|брак|перечекан|overstr|error|double|двойной удар|смещен|раскол|перепутк|выкус|залипух|непрочекан|гурт на канте|двойной кант/.test(name)) {
score += 12; reasons.push('Брак/ошибка чеканки — коллекционная редкость')
}
// Двойной аверс/реверс — особо ценный брак
if (/двойной аверс|двойной реверс|без реверса|без аверса|mule/.test(name)) {
score += 15; reasons.push('Мул/двойной аверс — крайне редкий брак')
}
// Без знака монетного двора — редкость
if (/без знака|без букв|no mint mark|безбуквен/.test(name)) {
score += 8; reasons.push('Без знака МД — редкий вариант')
}
// Ефимок / надчеканка — нумизматическая элита
if (/ефимок|efimok|надчеканк|counterstamp|countermark/.test(name)) {
score += 10; reasons.push('Ефимок/надчеканка — редкость, высокий спрос')
}
// ══════════════════════════════════
// 7. PRICE EFFICIENCY (max ~12 pts)
// Главное правило: покупай самую редкую монету в лучшем
// состоянии за доступную цену
// ══════════════════════════════════
if (coin.old_price && coin.old_price > coin.price) {
const discount = Math.round((coin.old_price - coin.price) / coin.old_price * 100)
if (discount >= 30) { score += 8; reasons.push(`Скидка ${discount}% — сильное снижение`) }
else if (discount >= 15) { score += 5; reasons.push(`Скидка ${discount}%`) }
else if (discount >= 10) { score += 2; reasons.push(`Скидка ${discount}%`) }
}
// AU+ за мало денег
if (gs >= gradeScore('AU') && price <= 500) { score += 6; reasons.push('AU+ дешевле 500₽ — ниже рынка') }
else if (gs >= gradeScore('UNC') && price <= 1000) { score += 4; reasons.push('UNC до 1000₽ — выгодная цена') }
// Общий бонус за дешевизну при хорошем грейде
if (gs >= gradeScore('VF') && price > 0 && price <= 3000) {
score += Math.max(0, Math.round((3000 - price) / 400))
}
// ══════════════════════════════════
// 8. NEGATIVE FACTORS (penalties)
// ══════════════════════════════════
// Массовые юбилейные СССР (не Proof, не драгмет) — нет вторичного рынка
if (/юбилейн|памятн|commemorat/.test(name) && year >= 1965 && year <= 1991) {
if (!/серебро|silver|золото|gold/.test(mat) && gs < gradeScore('Proof')) {
score -= 12; reasons.push('Массовые юбилейные СССР — низкий потенциал роста')
}
}
// Современные памятные монеты ЦБ РФ (не драгмет)
if (year >= 2000 && /памятн|юбилейн/.test(name) && !/серебро|silver|золото|gold/.test(mat)) {
score -= 8; reasons.push('Современные памятные — слабый вторичный рынок')
}
// Копии, жетоны, сувениры
if (/современн|сувенир|копия|replica|жетон|token|новодел/.test(name)) {
score -= 20; reasons.push('Не оригинальная монета')
}
// Чищеные монеты — резко теряют ценность (50-90% потеря по данным PCGS)
if (/чищен|cleaned|полиров|polish/.test(name) || /чищен|cleaned/.test(details.grade || '')) {
score -= 10; reasons.push('Чищеная монета — потеря 50-90% ценности')
}
// Патина на медных монетах — повышает ценность если оригинальная
if (/патин|patina|оригинальн.*поверхн/.test(name) && /медь|copper|бронз/.test(mat)) {
score += 3; reasons.push('Оригинальная патина — ценность для коллекционеров')
}
// Проволочные монеты (чешуя) — подделки распространены, но оригиналы ценны
if (/проволочн|чешуя|wire money/.test(name)) {
score -= 2; reasons.push('Проволочная монета — высокий риск подделки')
}
// Медно-никель без особой ценности (массовый чекан после 1950)
if (/медно.?никел|cupronickel|cu-ni|мельхиор/.test(mat) && year >= 1950 && gs < gradeScore('Proof')) {
if (!/ошибк|брак|error|редк/.test(name)) {
score -= 4
}
}
return { score: Math.round(Math.max(0, score)), reasons }
}
// ─── SSE progress ───
const scanClients = new Set()
function emitProgress(data) {
const msg = `data: ${JSON.stringify(data)}\n\n`
for (const res of scanClients) {
res.write(msg)
}
}
// ─── Scan logic ───
async function runScan() {
console.log('[scan] Starting full scan...')
emitProgress({ stage: 'start', message: 'Начинаю сканирование...' })
const logStmt = db.prepare('INSERT INTO scan_log (status) VALUES (?)')
const { lastInsertRowid: scanId } = logStmt.run('running')
let totalNew = 0
let totalRemoved = 0
let detailsParsed = 0
try {
// Fetch all feeds
const allItems = []
const loadedFeeds = new Set() // track which feeds loaded successfully
for (let fi = 0; fi < FEEDS.length; fi++) {
const feed = FEEDS[fi]
emitProgress({ stage: 'feed', message: `Загрузка фида ${feed.name}...`, feedIndex: fi, feedTotal: FEEDS.length })
let feedLoaded = false
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const items = await fetchFeed(feed)
if (items.length > 0) {
for (let j = 0; j < items.length; j++) allItems.push(items[j])
loadedFeeds.add(feed.id)
saveFeedCache(feed.id, items)
emitProgress({ stage: 'feed_done', message: `${feed.name}: ${items.length.toLocaleString()} товаров`, feedIndex: fi })
feedLoaded = true
break
} else if (attempt < 3) {
emitProgress({ stage: 'feed_error', message: `${feed.name}: пустой ответ, повтор ${attempt + 1}/3 через 10 сек...`, feedIndex: fi })
await new Promise(r => setTimeout(r, 10000))
}
} catch (e) {
if (attempt < 3) {
emitProgress({ stage: 'feed_error', message: `${feed.name}: ${e.message}, повтор ${attempt + 1}/3 через 10 сек...`, feedIndex: fi })
await new Promise(r => setTimeout(r, 10000))
}
}
}
// Fallback to disk cache if all attempts failed
if (!feedLoaded) {
const cached = loadFeedCache(feed.id)
if (cached && cached.length) {
for (let j = 0; j < cached.length; j++) allItems.push(cached[j])
loadedFeeds.add(feed.id)
emitProgress({ stage: 'feed_done', message: `${feed.name}: ${cached.length.toLocaleString()} товаров (из кеша)`, feedIndex: fi })
} else {
emitProgress({ stage: 'feed_error', message: `${feed.name}: не удалось загрузить, кеш отсутствует`, feedIndex: fi })
}
}
await new Promise(r => setTimeout(r, 3000))
}
// Get existing IDs
const existingIds = new Set(db.prepare('SELECT id FROM coins').all().map(r => r.id))
const newIds = new Set(allItems.map(i => i.id))
// Upsert coins
const upsert = db.prepare(`
INSERT INTO coins (id, feed, name, price, old_price, url, image, category, available, last_seen)
VALUES (@id, @feed, @name, @price, @old_price, @url, @image, @category, @available, datetime('now'))
ON CONFLICT(id) DO UPDATE SET
price = @price, old_price = @old_price, available = @available,
last_seen = datetime('now'), name = @name, url = @url, image = @image
`)
// Batch in chunks of 5000 to avoid stack overflow
const CHUNK = 5000
const totalChunks = Math.ceil(allItems.length / CHUNK)
const upsertMany = db.transaction((items) => {
for (const item of items) upsert.run(item)
})
for (let i = 0; i < allItems.length; i += CHUNK) {
const chunkNum = Math.floor(i / CHUNK) + 1
emitProgress({ stage: 'saving', message: `Сохранение монет: ${Math.min(i + CHUNK, allItems.length).toLocaleString()} / ${allItems.length.toLocaleString()}`, current: chunkNum, total: totalChunks })
upsertMany(allItems.slice(i, i + CHUNK))
}
// Upsert details from feed data (grade, material, weight etc.)
emitProgress({ stage: 'saving', message: `Сохранение деталей: 0 / ${allItems.length.toLocaleString()}` })
const detailFromFeed = db.prepare(`
INSERT INTO coin_details (coin_id, grade, material, weight, diameter, year_from, year_to, in_stock, parsed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(coin_id) DO UPDATE SET
grade = CASE WHEN excluded.grade != '' THEN excluded.grade ELSE coin_details.grade END,
material = CASE WHEN excluded.material != '' THEN excluded.material ELSE coin_details.material END,
weight = CASE WHEN excluded.weight != '' THEN excluded.weight ELSE coin_details.weight END,
diameter = CASE WHEN excluded.diameter != '' THEN excluded.diameter ELSE coin_details.diameter END,
year_from = CASE WHEN excluded.year_from IS NOT NULL THEN excluded.year_from ELSE coin_details.year_from END,
in_stock = excluded.in_stock,
parsed_at = datetime('now')
`)
const feedDetailBatch = db.transaction((items) => {
for (const item of items) {
if (!item.grade && !item.material && !item.year) continue
const yearNum = parseInt(item.year) || null
const mat = item.material || guessMaterial(item.name)
detailFromFeed.run(item.id, item.grade, mat, item.weight, item.diameter, yearNum, yearNum, item.available)
}
})
for (let i = 0; i < allItems.length; i += CHUNK) {
emitProgress({ stage: 'saving', message: `Сохранение деталей: ${Math.min(i + CHUNK, allItems.length).toLocaleString()} / ${allItems.length.toLocaleString()}`, current: Math.floor(i / CHUNK) + 1, total: totalChunks })
feedDetailBatch(allItems.slice(i, i + CHUNK))
}
// Count new
for (const item of allItems) {
if (!existingIds.has(item.id)) totalNew++
}
// Record price history — track price changes
emitProgress({ stage: 'saving', message: 'Запись истории цен...' })
const prevPrices = new Map()
db.prepare('SELECT id, price FROM coins').all().forEach(r => prevPrices.set(r.id, r.price))
const insertHistory = db.prepare('INSERT INTO price_history (coin_id, price) VALUES (?, ?)')
const historyBatch = db.transaction((items) => {
for (const item of items) {
const prev = prevPrices.get(item.id)
// Record if: new coin, or price changed
if (prev === undefined || (prev !== item.price && item.price > 0)) {
insertHistory.run(item.id, item.price)
}
}
})
for (let i = 0; i < allItems.length; i += CHUNK) {
historyBatch(allItems.slice(i, i + CHUNK))
}
// Mark removed — only for feeds that loaded successfully
const markUnavailable = db.prepare('UPDATE coins SET available = 0 WHERE id = ?')
const existingWithFeed = db.prepare('SELECT id, feed FROM coins').all()
for (const row of existingWithFeed) {
if (loadedFeeds.has(row.feed) && !newIds.has(row.id)) {
markUnavailable.run(row.id)
totalRemoved++
}
}
// Details are now extracted directly from feed data (grade, material, weight)
// No need to parse individual product pages
db.prepare('UPDATE scan_log SET finished_at = datetime(\'now\'), new_coins = ?, removed_coins = ?, details_parsed = ?, status = ? WHERE id = ?')
.run(totalNew, totalRemoved, detailsParsed, 'done', scanId)
emitProgress({ stage: 'done', message: `Готово! Новых: ${totalNew}, удалено: ${totalRemoved}, деталей: ${detailsParsed}`, totalNew, totalRemoved, detailsParsed })
console.log(`[scan] Done. New: ${totalNew}, Removed: ${totalRemoved}, Parsed: ${detailsParsed}`)
} catch (e) {
console.error(`[scan] Error: ${e.message}`)
emitProgress({ stage: 'error', message: `Ошибка: ${e.message}` })
db.prepare('UPDATE scan_log SET finished_at = datetime(\'now\'), status = ? WHERE id = ?')
.run('error: ' + e.message, scanId)
}
return { totalNew, totalRemoved, detailsParsed }
}
// ─── Settings helpers ───
function getSettings() {
const rows = db.prepare('SELECT key, value FROM settings').all()
return Object.fromEntries(rows.map(r => [r.key, r.value]))
}
// ─── API routes ───
// Get hot coins (filtered & scored)
app.get('/api/coins', (req, res) => {
const settings = getSettings()
const maxPrice = parseFloat(req.query.max_price || settings.max_price) || 3000
const minGrade = req.query.min_grade || settings.min_grade || 'VF'
const materialFilter = (req.query.material || settings.preferred_material || '').toLowerCase()
const onlyInStock = req.query.in_stock !== '0'
const feedFilter = req.query.feed || ''
const limit = parseInt(req.query.limit) || 200
const offset = parseInt(req.query.offset) || 0
let coins
if (feedFilter) {
coins = db.prepare(`
SELECT c.*, d.grade, d.material, d.weight, d.diameter, d.year_from, d.year_to, d.country, d.in_stock, d.parsed_at
FROM coins c LEFT JOIN coin_details d ON d.coin_id = c.id
WHERE c.available = 1 AND c.price > 0 AND c.price <= ? AND c.feed = ?
ORDER BY c.first_seen DESC
`).all(maxPrice, feedFilter)
} else {
coins = db.prepare(`
SELECT c.*, d.grade, d.material, d.weight, d.diameter, d.year_from, d.year_to, d.country, d.in_stock, d.parsed_at
FROM coins c LEFT JOIN coin_details d ON d.coin_id = c.id
WHERE c.available = 1 AND c.price > 0 AND c.price <= ?
ORDER BY c.first_seen DESC
`).all(maxPrice)
}
// Filter out non-coins (albums, capsules, postcards etc.)
coins = coins.filter(c => isCoin(c.name))
// Filter by grade
if (minGrade) {
coins = coins.filter(c => c.grade && gradeAtLeast(c.grade, minGrade))
}
// Filter by stock
if (onlyInStock) {
coins = coins.filter(c => c.in_stock === 1 || c.in_stock === null)
}
// Enrich material from name if not parsed
coins = coins.map(c => ({
...c,
material: c.material || guessMaterial(c.name),
}))
// Filter by material
if (materialFilter && materialFilter !== 'любой') {
const mats = materialFilter.split(',').map(m => m.trim())
coins = coins.filter(c => {
if (!c.material) return false
const cm = c.material.toLowerCase()
return mats.some(m => cm.includes(m))
})
}
// Score & sort with reasons
coins = coins.map(c => {
const { score, reasons } = investmentScore(c, {
grade: c.grade, material: c.material,
year_from: c.year_from, year_to: c.year_to,
weight: c.weight,
})
const summary = buildSummary(c, { grade: c.grade, material: c.material, year_from: c.year_from, weight: c.weight }, score, reasons)
return { ...c, score, reasons, summary: summary.short, analysis: summary.full, emailCopy: summary.email }
}).sort((a, b) => b.score - a.score)
// Deduplicate: group by name+price+grade+material, keep first (highest score)
const hideDupes = req.query.hide_dupes !== '0' && (req.query.hide_dupes === '1' || settings.hide_dupes === '1')
if (hideDupes) {
const seen = new Set()
coins = coins.filter(c => {
const key = `${c.name}|${c.price}|${c.grade}|${c.material}`
if (seen.has(key)) return false
seen.add(key)
return true
})
}
const total = coins.length
coins = coins.slice(offset, offset + limit)
res.json({ coins, total, filters: { maxPrice, minGrade, material: materialFilter, onlyInStock, hideDupes } })
})
// Get all coins (unfiltered, for browsing)
app.get('/api/coins/all', (req, res) => {
const limit = parseInt(req.query.limit) || 100
const offset = parseInt(req.query.offset) || 0
const coins = db.prepare(`
SELECT c.*, d.grade, d.material, d.in_stock, d.parsed_at
FROM coins c
LEFT JOIN coin_details d ON d.coin_id = c.id
WHERE c.available = 1 AND c.price > 0
ORDER BY c.price ASC
LIMIT ? OFFSET ?
`).all(limit, offset)
const total = db.prepare('SELECT COUNT(*) as cnt FROM coins WHERE available = 1 AND price > 0').get().cnt
res.json({ coins, total })
})
// Get new arrivals (coins first seen in last N days)
app.get('/api/coins/new', (req, res) => {
const days = parseInt(req.query.days) || 7
const coins = db.prepare(`
SELECT c.*, d.grade, d.material, d.weight, d.year_from, d.country, d.in_stock
FROM coins c
LEFT JOIN coin_details d ON d.coin_id = c.id
WHERE c.available = 1 AND c.price > 0
AND c.first_seen >= datetime('now', '-' || ? || ' days')
ORDER BY c.first_seen DESC
`).all(days)
res.json({ coins, total: coins.length })
})
// Settings
app.get('/api/settings', (req, res) => {
res.json(getSettings())
})
app.put('/api/settings', (req, res) => {
const upsert = db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?')
for (const [k, v] of Object.entries(req.body)) {
upsert.run(k, String(v), String(v))
}
res.json({ ok: true })
})
// Trigger manual scan
let scanRunning = false
app.post('/api/scan', async (req, res) => {
if (scanRunning) return res.json({ status: 'already_running' })
scanRunning = true
res.json({ status: 'started' })
try {
await runScan()
} finally {
scanRunning = false
}
})
// SSE scan progress
app.get('/api/scan/progress', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
})
scanClients.add(res)
req.on('close', () => scanClients.delete(res))
})
// Scan status
app.get('/api/scan/status', (req, res) => {
const last = db.prepare('SELECT * FROM scan_log ORDER BY id DESC LIMIT 1').get()
res.json({ running: scanRunning, last: last || null })
})
// Stats
// ─── Dashboard stats ───
app.get('/api/stats', (req, res) => {
const total = db.prepare('SELECT COUNT(*) as cnt FROM coins WHERE available = 1').get().cnt
const withDetails = db.prepare('SELECT COUNT(*) as cnt FROM coin_details WHERE parsed_at IS NOT NULL').get().cnt
const feeds = db.prepare('SELECT feed, COUNT(*) as cnt FROM coins WHERE available = 1 GROUP BY feed').all()
res.json({ total, withDetails, feeds })
})
app.get('/api/dashboard', (req, res) => {
const total = db.prepare('SELECT COUNT(*) as cnt FROM coins WHERE available = 1').get().cnt
const feeds = db.prepare('SELECT feed, COUNT(*) as cnt, ROUND(AVG(price),0) as avg_price FROM coins WHERE available = 1 AND price > 0 GROUP BY feed').all()
// New in last 7 days
const newThisWeek = db.prepare("SELECT COUNT(*) as cnt FROM coins WHERE first_seen >= datetime('now', '-7 days')").get().cnt
// Price drops (coins where current price < previous recorded price)
const priceDrops = db.prepare(`
SELECT c.id, c.name, c.feed, c.price, c.url, c.image, ph.price as prev_price,
ROUND((ph.price - c.price) / ph.price * 100) as drop_pct
FROM coins c
JOIN (
SELECT coin_id, price, ROW_NUMBER() OVER (PARTITION BY coin_id ORDER BY recorded_at DESC) as rn
FROM price_history
) ph ON ph.coin_id = c.id AND ph.rn = 2
WHERE c.available = 1 AND c.price < ph.price AND c.price > 0
ORDER BY drop_pct DESC
LIMIT 20
`).all()
// Price rises
const priceRises = db.prepare(`
SELECT c.id, c.name, c.feed, c.price, c.url, ph.price as prev_price,
ROUND((c.price - ph.price) / ph.price * 100) as rise_pct
FROM coins c
JOIN (
SELECT coin_id, price, ROW_NUMBER() OVER (PARTITION BY coin_id ORDER BY recorded_at DESC) as rn
FROM price_history
) ph ON ph.coin_id = c.id AND ph.rn = 2
WHERE c.available = 1 AND c.price > ph.price AND c.price > 0
ORDER BY rise_pct DESC
LIMIT 20
`).all()
// Recently disappeared (was available, now not)
const disappeared = db.prepare(`
SELECT id, name, feed, price, url FROM coins
WHERE available = 0 AND last_seen >= datetime('now', '-7 days')
ORDER BY last_seen DESC LIMIT 20
`).all()
// Material breakdown
const materials = db.prepare(`
SELECT d.material, COUNT(*) as cnt, ROUND(AVG(c.price),0) as avg_price
FROM coin_details d JOIN coins c ON c.id = d.coin_id
WHERE c.available = 1 AND d.material != '' AND c.price > 0
GROUP BY d.material ORDER BY cnt DESC LIMIT 15
`).all()
// Grade breakdown
const grades = db.prepare(`
SELECT d.grade, COUNT(*) as cnt, ROUND(AVG(c.price),0) as avg_price
FROM coin_details d JOIN coins c ON c.id = d.coin_id
WHERE c.available = 1 AND d.grade != '' AND c.price > 0
GROUP BY d.grade ORDER BY cnt DESC LIMIT 15
`).all()
res.json({ total, feeds, newThisWeek, priceDrops, priceRises, disappeared, materials, grades })
})
// ─── Price history for a coin ───
app.get('/api/coins/:id/history', (req, res) => {
const history = db.prepare('SELECT price, recorded_at FROM price_history WHERE coin_id = ? ORDER BY recorded_at ASC').all(req.params.id)
res.json({ history })
})
// ─── Cross-store comparison ───
app.get('/api/compare', (req, res) => {
// Find coins with same name across different feeds
const dupes = db.prepare(`
SELECT c1.name, c1.feed as feed1, c1.price as price1, c1.url as url1,
c2.feed as feed2, c2.price as price2, c2.url as url2,
ROUND(ABS(c1.price - c2.price) / MAX(c1.price, c2.price) * 100) as diff_pct
FROM coins c1
JOIN coins c2 ON c1.name = c2.name AND c1.feed < c2.feed
WHERE c1.available = 1 AND c2.available = 1 AND c1.price > 0 AND c2.price > 0
ORDER BY diff_pct DESC
LIMIT 50
`).all()
res.json({ comparisons: dupes })
})
// Parse details for a specific coin (manual)
app.post('/api/coins/:id/parse', async (req, res) => {
const coin = db.prepare('SELECT * FROM coins WHERE id = ?').get(req.params.id)
if (!coin) return res.status(404).json({ error: 'not found' })
const details = await parseProductPage(coin.url)
if (!details) return res.status(500).json({ error: 'parse failed' })
db.prepare(`
INSERT INTO coin_details (coin_id, grade, material, weight, diameter, year_from, year_to, country, in_stock, parsed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(coin_id) DO UPDATE SET
grade = ?, material = ?, weight = ?, diameter = ?, year_from = ?, year_to = ?, country = ?, in_stock = ?, parsed_at = datetime('now')
`).run(coin.id, details.grade, details.material, details.weight, details.diameter, details.year_from, details.year_to, details.country, details.in_stock,
details.grade, details.material, details.weight, details.diameter, details.year_from, details.year_to, details.country, details.in_stock)
res.json({ ok: true, details })
})
// ─── Cron ───
const settings = getSettings()
const hour = parseInt(settings.auto_scan_hour) || 8
cron.schedule(`0 ${hour} * * *`, async () => {
const s = getSettings()
if (s.scan_enabled !== '1') return
console.log('[cron] Scheduled scan starting...')
if (!scanRunning) {
scanRunning = true
try { await runScan() } finally { scanRunning = false }
}
})
// ─── Start ───
app.listen(PORT, () => {
console.log(`Coin Scout running on http://localhost:${PORT}`)
console.log(`Database: ${DB_PATH}`)
console.log(`Feeds: ${FEEDS.map(f => f.name).join(', ')}`)
console.log(`Auto-scan: daily at ${hour}:00`)
})