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(``, 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`) })