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 sharp = require('sharp') const { generateCoinEmail, setGradeScore, setSilverPrice } = require('./coin-writer') // ─── Visual similarity via perceptual hash ─── async function getImageHash(url) { try { const res = await fetch(url, { signal: AbortSignal.timeout(8000), headers: { 'User-Agent': 'Mozilla/5.0 (compatible; CoinScout/1.0)' }, }) if (!res.ok) return null const buffer = Buffer.from(await res.arrayBuffer()) // Generate 3 crops at different scales to handle zoom differences const SZ = 64 const big = await sharp(buffer).resize(SZ * 3, SZ * 3, { fit: 'fill' }).removeAlpha().normalise().toBuffer() const crops = [] // Full image const full = await sharp(big).resize(SZ, SZ).raw().toBuffer() const fullG = await sharp(big).resize(SZ, SZ).grayscale().raw().toBuffer() crops.push({ color: full, gray: fullG }) // Center 66% crop const off1 = Math.floor(SZ * 3 * 0.17) const sz1 = SZ * 3 - off1 * 2 const c1 = await sharp(big).extract({ left: off1, top: off1, width: sz1, height: sz1 }).resize(SZ, SZ).raw().toBuffer() const g1 = await sharp(big).extract({ left: off1, top: off1, width: sz1, height: sz1 }).resize(SZ, SZ).grayscale().raw().toBuffer() crops.push({ color: c1, gray: g1 }) // Center 40% crop const off2 = Math.floor(SZ * 3 * 0.30) const sz2 = SZ * 3 - off2 * 2 const c2 = await sharp(big).extract({ left: off2, top: off2, width: sz2, height: sz2 }).resize(SZ, SZ).raw().toBuffer() const g2 = await sharp(big).extract({ left: off2, top: off2, width: sz2, height: sz2 }).resize(SZ, SZ).grayscale().raw().toBuffer() crops.push({ color: c2, gray: g2 }) // Also a horizontally flipped version of full (for mirrored photos) const flipped = await sharp(big).flop().resize(SZ, SZ).raw().toBuffer() const flippedG = await sharp(big).flop().resize(SZ, SZ).grayscale().raw().toBuffer() crops.push({ color: flipped, gray: flippedG }) return crops } catch (e) { return null } } function comparePair(a, b) { const SZ = 64 const px = a.color.length / 3 // 1. Color histogram (scale-invariant) function colorHist(buf) { const h = [new Float64Array(16), new Float64Array(16), new Float64Array(16)] for (let i = 0; i < px; i++) { for (let ch = 0; ch < 3; ch++) h[ch][Math.min(15, buf[i * 3 + ch] >> 4)]++ } for (let ch = 0; ch < 3; ch++) for (let b = 0; b < 16; b++) h[ch][b] /= px return h } const ch1 = colorHist(a.color), ch2 = colorHist(b.color) let histSim = 0 for (let ch = 0; ch < 3; ch++) { let inter = 0 for (let b = 0; b < 16; b++) inter += Math.min(ch1[ch][b], ch2[ch][b]) histSim += inter } histSim /= 3 // 2. Block-average spatial layout (8×8 grid) const BLOCKS = 8, bsz = SZ / BLOCKS function blockAvgs(gray) { const avgs = new Float64Array(BLOCKS * BLOCKS) for (let by = 0; by < BLOCKS; by++) for (let bx = 0; bx < BLOCKS; bx++) { let sum = 0 for (let y = by * bsz; y < (by + 1) * bsz; y++) for (let x = bx * bsz; x < (bx + 1) * bsz; x++) sum += gray[y * SZ + x] avgs[by * BLOCKS + bx] = sum / (bsz * bsz) } return avgs } const ba1 = blockAvgs(a.gray), ba2 = blockAvgs(b.gray) let dot = 0, m1 = 0, m2 = 0 for (let i = 0; i < ba1.length; i++) { dot += ba1[i] * ba2[i]; m1 += ba1[i] * ba1[i]; m2 += ba2[i] * ba2[i] } const blockSim = (m1 === 0 || m2 === 0) ? 0 : dot / (Math.sqrt(m1) * Math.sqrt(m2)) // 3. pHash — relative brightness function phash(avgs) { const sorted = [...avgs].sort((a, b) => a - b) const med = sorted[Math.floor(sorted.length / 2)] return avgs.map(v => v > med ? 1 : 0) } const ph1 = phash(ba1), ph2 = phash(ba2) let matching = 0 for (let i = 0; i < ph1.length; i++) if (ph1[i] === ph2[i]) matching++ const phashSim = matching / ph1.length const raw = histSim * 0.35 + blockSim * 0.35 + phashSim * 0.30 return Math.max(0, Math.min(1, (raw - 0.45) * 1.82)) // 0.45→0%, 1.0→100% } function compareHashes(crops1, crops2) { if (!crops1 || !crops2) return null // Compare all crop combinations, take best match let best = 0 for (const a of crops1) { for (const b of crops2) { const sim = comparePair(a, b) if (sim > best) best = sim } } return Math.round(best * 100) } 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() // ─── Fix bad years on startup ─── ;(function fixYears() { const bad = db.prepare(` SELECT d.coin_id, c.name, d.year_from FROM coin_details d JOIN coins c ON c.id = d.coin_id WHERE d.year_from IS NOT NULL `).all() const update = db.prepare('UPDATE coin_details SET year_from = ?, year_to = ? WHERE coin_id = ?') let fixed = 0 for (const row of bad) { const correct = extractYear(row.name, null) if (correct && correct !== row.year_from) { update.run(correct, correct, row.coin_id) fixed++ } else if (!correct && row.year_from > 2030) { update.run(null, null, row.coin_id) fixed++ } } if (fixed) console.log(`[years] Fixed ${fixed} incorrect year values`) })() // ─── 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 and silver price into coin-writer setGradeScore(gradeScore) setSilverPrice(() => SILVER_PRICE_PER_GRAM) // ─── 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 } if (/\bгод\b|year/i.test(text) && !/тираж|выпуск/.test(text)) { const ym = rawVal.match(/(\d{3,4})/) if (ym) { const y = parseInt(ym[1]) if (y >= 100 && y <= 2030) { result.year_from = y result.year_to = y } } } } // 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 — prefer table value, then name, then URL if (!result.year_from) { // From coin name: "2 копейки 1909 года" or "1 солид 1621 года" or "1610-1612 года" const nameFromUrl = decodeURIComponent(url).replace(/_/g, ' ') const nameText = nameFromUrl + ' ' + (root.querySelector('h1') || { text: '' }).text const rangeMatch = nameText.match(/(\d{3,4})\s*[-–]\s*(\d{3,4})\s*(?:год|г\.|г\b)/i) if (rangeMatch) { const y1 = parseInt(rangeMatch[1]), y2 = parseInt(rangeMatch[2]) if (y1 >= 100 && y1 <= 2030 && y2 >= y1 && y2 <= 2030) { result.year_from = y1 result.year_to = y2 } } if (!result.year_from) { const singleMatch = nameText.match(/(\d{4})\s*(?:год|г\.|г\b|_goda)/i) if (singleMatch) { const y = parseInt(singleMatch[1]) if (y >= 100 && y <= 2030) { result.year_from = y result.year_to = y } } } // Fallback: URL pattern "1234_goda" if (!result.year_from) { const urlYear = url.match(/(\d{4})_goda/) if (urlYear) { const y = parseInt(urlYear[1]) if (y >= 100 && y <= 2030) { result.year_from = y result.year_to = y } } } } return result } catch (e) { console.error(`[parse] Error parsing ${url}: ${e.message}`) return null } } // ─── Guess material from coin name ─── // ─── Year extraction with validation ─── function extractYear(name, feedYear) { const n = name || '' // 1. Range in name: "10-147 года", "1610-1612 года" const rangeMatch = n.match(/\b(\d{1,4})\s*[-–]\s*(\d{1,4})\s*(?:год|г\.|г\b)/i) if (rangeMatch) { const y1 = parseInt(rangeMatch[1]), y2 = parseInt(rangeMatch[2]) if (y1 >= 1 && y1 <= 2030 && y2 >= y1 && y2 <= 2030) return y1 } // 2. Single 4-digit year in name: "2 копейки 1909 года" const fourDigit = n.match(/\b(1[0-9]{3}|20[0-2][0-9])\s*(?:год|г\.|г\b)/i) if (fourDigit) { const y = parseInt(fourDigit[1]) if (y >= 100 && y <= 2030) return y } // 3. Any 4-digit year-like number in name (fallback) const anyFour = n.match(/\b(1[0-9]{3}|20[0-2][0-9])\b/) if (anyFour) { const y = parseInt(anyFour[1]) if (y >= 100 && y <= 2030) return y } // 4. Validate feed year (could be SH/AH calendar, article numbers etc.) if (feedYear) { const y = parseInt(feedYear) // Only accept if it looks like a plausible Gregorian year if (y >= 100 && y <= 2030) return y } return null } // ─── Country extraction from coin name ─── const COUNTRY_PATTERNS = [ { re: /россий|русск|спб|москв|ммд|спмд|империя|копе[ей]к|рубл|полтин|гривен|деньга|алтын|полушк/i, country: 'Россия' }, { re: /ссср|советск/i, country: 'СССР' }, { re: /герман|рейхсмарк|пфенниг|марок\b|марки\b/i, country: 'Германия' }, { re: /франц|франк[аов]?\b|сантим/i, country: 'Франция' }, { re: /англи|британ|пенни|шиллинг|фартинг|крон.*англ/i, country: 'Великобритания' }, { re: /итали|лир[аы]?\b|чентезимо/i, country: 'Италия' }, { re: /испан|песет|мараведи|реал.*испан/i, country: 'Испания' }, { re: /япони|иен[аы]?\b|сен\b.*япон/i, country: 'Япония' }, { re: /кита[йи]|юан[ьей]|цзяо|фэн/i, country: 'Китай' }, { re: /сша|амери|цент[аов]?\b.*сша|долл.*сша/i, country: 'США' }, { re: /польш|злот|грош.*польш/i, country: 'Польша' }, { re: /австри|крейцер|геллер.*австр/i, country: 'Австрия' }, { re: /нидерланд|голланд|гульден/i, country: 'Нидерланды' }, { re: /швеци|крон.*швец|эре\b/i, country: 'Швеция' }, { re: /норвеги|крон.*норвег/i, country: 'Норвегия' }, { re: /дани[яи]|крон.*дан|эре.*дан/i, country: 'Дания' }, { re: /финлянд|пенни.*финл|марк.*финл/i, country: 'Финляндия' }, { re: /турци|турецк|куруш|пиастр.*тур/i, country: 'Турция' }, { re: /иран|риал.*иран/i, country: 'Иран' }, { re: /индии|инди[яи]|рупи|пайс|анн[аы]/i, country: 'Индия' }, { re: /римск|roman|денарий|антониниан|сестерци|аурей/i, country: 'Рим' }, { re: /греч|greek|драхм|обол|тетрадрахм/i, country: 'Греция' }, { re: /визант|byzant|солид.*визант/i, country: 'Византия' }, { re: /боспор|пантикапей/i, country: 'Боспор' }, { re: /осман|ottoman/i, country: 'Османская империя' }, { re: /грузи|тетри|лари.*груз/i, country: 'Грузия' }, { re: /украин|гривн[аы]|копійк/i, country: 'Украина' }, { re: /белорус|белару/i, country: 'Беларусь' }, { re: /казахстан|тенге|тиын/i, country: 'Казахстан' }, { re: /австрали|australian/i, country: 'Австралия' }, { re: /канад|canadian/i, country: 'Канада' }, { re: /швейцари|раппен|франк.*швейц/i, country: 'Швейцария' }, { re: /португал|эскудо|рейс.*порт/i, country: 'Португалия' }, { re: /чехословак|чехи[яи]|крон.*чех|геллер.*чех/i, country: 'Чехия' }, { re: /венгри|форинт|филлер|крейцер.*венг/i, country: 'Венгрия' }, { re: /египет|egypt|пиастр.*егип/i, country: 'Египет' }, { re: /мекси|песо.*мекс/i, country: 'Мексика' }, { re: /бразили/i, country: 'Бразилия' }, { re: /аргентин/i, country: 'Аргентина' }, ] // ─── Detect mint errors and varieties ─── function detectSpecial(name) { const n = (name || '').toLowerCase() const tags = [] if (/брак|ошибк|error|двойной удар|смещен|раскол|перепутк|выкус|залипух|непрочекан|край листа|двойной кант/.test(n)) tags.push('Брак') if (/двойной аверс|двойной реверс|без реверса|без аверса|mule/.test(n)) tags.push('Мул') if (/перечекан|overstr|overdate|передат/.test(n)) tags.push('Перечекан') if (/разновидн|вариант|variety/.test(n)) tags.push('Разновидность') if (/пробн|проба|pattern|trial|essai/.test(n)) tags.push('Пробная') if (/новодел|novodel|restrike/.test(n)) tags.push('Новодел') return tags } function guessCountry(name) { const n = (name || '').toLowerCase() for (const p of COUNTRY_PATTERNS) { if (p.re.test(n)) return p.country } return '' } 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|подставк|футляр|лупа|пинцет|рамк|планшет|холдер|книг|облигаци|почтовая марк|вексел|закладн[ао]|сертификат|письмо\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 per gram (fetched from ЦБ РФ daily, fallback 200) let SILVER_PRICE_PER_GRAM = 200 async function fetchSilverPrice() { try { const res = await fetch('https://www.cbr.ru/scripts/xml_metall.asp?date_req1=' + formatDateCBR(new Date()) + '&date_req2=' + formatDateCBR(new Date())) const text = await res.text() // Silver code=2, price per gram in XML const match = text.match(/]*Code="2"[^>]*>[\s\S]*?([\d,.]+)<\/Buy>/) if (match) { const price = parseFloat(match[1].replace(',', '.')) if (price > 0) { SILVER_PRICE_PER_GRAM = Math.round(price) console.log(`[silver] Updated price: ${SILVER_PRICE_PER_GRAM} ₽/g (ЦБ РФ)`) } } } catch (e) { console.log(`[silver] Failed to fetch from ЦБ, using ${SILVER_PRICE_PER_GRAM} ₽/g`) } } function formatDateCBR(d) { return String(d.getDate()).padStart(2, '0') + '/' + String(d.getMonth() + 1).padStart(2, '0') + '/' + d.getFullYear() } // Fetch on startup and daily fetchSilverPrice() cron.schedule('0 9 * * *', fetchSilverPrice) 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 let yearNum = extractYear(item.name, item.year) 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++ } } // Auto-parse product pages for coins missing details (grade/material) const unparsed = db.prepare(` SELECT c.id, c.url FROM coins c LEFT JOIN coin_details d ON d.coin_id = c.id WHERE c.available = 1 AND (d.coin_id IS NULL OR (d.grade = '' AND d.material = '')) ORDER BY c.price DESC LIMIT 200 `).all() if (unparsed.length) { emitProgress({ stage: 'parsing', message: `Парсинг деталей: 0 / ${unparsed.length}` }) const upsertDetail = 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 = 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, year_to = CASE WHEN excluded.year_to IS NOT NULL THEN excluded.year_to ELSE coin_details.year_to END, country = CASE WHEN excluded.country != '' THEN excluded.country ELSE coin_details.country END, in_stock = excluded.in_stock, parsed_at = datetime('now') `) for (let i = 0; i < unparsed.length; i++) { try { const det = await parseProductPage(unparsed[i].url) if (det) { upsertDetail.run(unparsed[i].id, det.grade, det.material, det.weight, det.diameter, det.year_from, det.year_to, det.country, det.in_stock) detailsParsed++ } } catch (e) {} if (i % 10 === 0) { emitProgress({ stage: 'parsing', message: `Парсинг деталей: ${i + 1} / ${unparsed.length}` }) await new Promise(r => setTimeout(r, 500)) // rate limit } } emitProgress({ stage: 'parsing', message: `Парсинг деталей: ${unparsed.length} / ${unparsed.length} завершён` }) } 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 countryFilter = (req.query.country || '').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 and country from name if not parsed coins = coins.map(c => ({ ...c, material: c.material || guessMaterial(c.name), country: c.country || guessCountry(c.name), special: detectSpecial(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)) }) } // Filter by country if (countryFilter && countryFilter !== 'все') { coins = coins.filter(c => c.country && c.country.toLowerCase().includes(countryFilter)) } // 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/countries', (req, res) => { const coins = db.prepare('SELECT name FROM coins WHERE available = 1 LIMIT 10000').all() const counts = {} for (const c of coins) { const country = guessCountry(c.name) if (country) counts[country] = (counts[country] || 0) + 1 } const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]).map(([name, cnt]) => ({ name, cnt })) res.json({ countries: sorted }) }) app.get('/api/silver-price', (req, res) => { res.json({ price: SILVER_PRICE_PER_GRAM }) }) 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 available = 1 AND 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() // Parse stats const totalCoins = db.prepare('SELECT COUNT(*) as cnt FROM coins WHERE available = 1').get().cnt const parsedCoins = db.prepare('SELECT COUNT(*) as cnt FROM coin_details d JOIN coins c ON c.id = d.coin_id WHERE c.available = 1 AND d.parsed_at IS NOT NULL').get().cnt const unparsedCoins = totalCoins - parsedCoins const parseRate = totalCoins > 0 ? Math.round(parsedCoins / totalCoins * 100) : 0 const parseFails = db.prepare(` SELECT c.feed, COUNT(*) as cnt FROM coins c LEFT JOIN coin_details d ON d.coin_id = c.id WHERE c.available = 1 AND d.coin_id IS NULL GROUP BY c.feed `).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() // Daily trends (last 14 days) const dailyNew = db.prepare(` SELECT DATE(first_seen) as day, COUNT(*) as cnt FROM coins WHERE available = 1 AND first_seen >= datetime('now', '-14 days') GROUP BY DATE(first_seen) ORDER BY day `).all() // Scan history const scanHistory = db.prepare(` SELECT DATE(started_at) as day, COUNT(*) as scans, SUM(new_coins) as new_coins FROM scan_log WHERE started_at >= datetime('now', '-14 days') GROUP BY DATE(started_at) ORDER BY day `).all() // Top finds this week (highest score) const topWeek = db.prepare(` SELECT c.id, c.name, c.feed, c.price, c.url, c.image, d.grade, d.material FROM coins c LEFT JOIN coin_details d ON d.coin_id = c.id WHERE c.available = 1 AND c.first_seen >= datetime('now', '-7 days') AND c.price > 0 ORDER BY c.price DESC LIMIT 10 `).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, silverPrice: SILVER_PRICE_PER_GRAM, parseStats: { parsed: parsedCoins, unparsed: unparsedCoins, rate: parseRate, failsByFeed: parseFails }, dailyNew, scanHistory, topWeek }) }) // ─── 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) => { const minDiff = parseInt(req.query.min_diff) || 50 const limit = parseInt(req.query.limit) || 500 // 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, c1.image as image1, c2.feed as feed2, c2.price as price2, c2.url as url2, c2.image as image2, COALESCE(d1.grade, '') as grade1, COALESCE(d2.grade, '') as grade2, COALESCE(d1.material, '') as material1, COALESCE(d2.material, '') as material2, d1.year_from as year_from, d1.weight as weight1, d2.weight as weight2, 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 LEFT JOIN coin_details d1 ON d1.coin_id = c1.id LEFT JOIN coin_details d2 ON d2.coin_id = c2.id WHERE c1.available = 1 AND c2.available = 1 AND c1.price >= 100 AND c2.price >= 100 AND COALESCE(d1.grade, '') = COALESCE(d2.grade, '') AND COALESCE(d1.material, '') = COALESCE(d2.material, '') AND (d1.weight IS NULL OR d2.weight IS NULL OR d1.weight = '' OR d2.weight = '' OR d1.weight = d2.weight) AND ROUND(ABS(c1.price - c2.price) / MAX(c1.price, c2.price) * 100) >= ? ORDER BY diff_pct DESC LIMIT ? `).all(minDiff, limit).map(c => { const cheaperPrice = Math.min(c.price1, c.price2) const mat = c.material1 || guessMaterial(c.name) const { score } = investmentScore( { name: c.name, price: cheaperPrice }, { grade: c.grade1, material: mat, year_from: c.year_from, weight: c.weight1 || c.weight2 } ) return { ...c, score, material: mat, weight: c.weight1 || c.weight2 } }) res.json({ comparisons: dupes }) }) // ─── CSV export of comparisons ─── app.get('/api/compare/csv', (req, res) => { const minDiff = parseInt(req.query.min_diff) || 50 const limit = parseInt(req.query.limit) || 5000 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, COALESCE(d1.grade, '') as grade, COALESCE(d1.material, '') as material, COALESCE(d1.weight, '') as weight, d1.year_from as year, 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 LEFT JOIN coin_details d1 ON d1.coin_id = c1.id LEFT JOIN coin_details d2 ON d2.coin_id = c2.id WHERE c1.available = 1 AND c2.available = 1 AND c1.price >= 100 AND c2.price >= 100 AND COALESCE(d1.grade, '') = COALESCE(d2.grade, '') AND COALESCE(d1.material, '') = COALESCE(d2.material, '') AND (d1.weight IS NULL OR d2.weight IS NULL OR d1.weight = '' OR d2.weight = '' OR d1.weight = d2.weight) AND ROUND(ABS(c1.price - c2.price) / MAX(c1.price, c2.price) * 100) >= ? ORDER BY diff_pct DESC LIMIT ? `).all(minDiff, limit) const feedNames = { AT: 'numizm.at', KB: 'coinsbolhov.ru', RU: 'numizmat.ru' } const esc = v => { if (v == null) return '' const s = String(v) return /[",\n;]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s } const rows = [ ['Монета', 'Год', 'Грейд', 'Материал', 'Вес', 'Скор', 'Магазин дешевле', 'Цена дешевле', 'Ссылка дешевле', 'Магазин дороже', 'Цена дороже', 'Ссылка дороже', 'Разница ₽', 'Разница %'].join(';') ] for (const c of dupes) { const mat = c.material || guessMaterial(c.name) const { score } = investmentScore( { name: c.name, price: Math.min(c.price1, c.price2) }, { grade: c.grade, material: mat, year_from: c.year, weight: c.weight } ) const cheaper1 = c.price1 <= c.price2 const cheapPrice = cheaper1 ? c.price1 : c.price2 const expPrice = cheaper1 ? c.price2 : c.price1 const cheapFeed = cheaper1 ? c.feed1 : c.feed2 const expFeed = cheaper1 ? c.feed2 : c.feed1 const cheapUrl = cheaper1 ? c.url1 : c.url2 const expUrl = cheaper1 ? c.url2 : c.url1 rows.push([ esc(c.name), esc(c.year || ''), esc(c.grade), esc(mat), esc(c.weight), esc(score), esc(feedNames[cheapFeed] || cheapFeed), esc(cheapPrice), esc(cheapUrl), esc(feedNames[expFeed] || expFeed), esc(expPrice), esc(expUrl), esc(expPrice - cheapPrice), esc(c.diff_pct) ].join(';')) } const csv = '\ufeff' + rows.join('\r\n') // BOM for Excel UTF-8 const fname = `coin-scout-compare-${new Date().toISOString().slice(0, 10)}.csv` res.setHeader('Content-Type', 'text/csv; charset=utf-8') res.setHeader('Content-Disposition', `attachment; filename="${fname}"`) res.send(csv) }) // ─── Visual similarity for compare results ─── app.post('/api/compare/similarity', async (req, res) => { const pairs = req.body.pairs || [] // [{image1, image2}, ...] const results = [] for (const pair of pairs.slice(0, 50)) { // max 50 at a time if (!pair.image1 || !pair.image2) { results.push(null); continue } const [h1, h2] = await Promise.all([getImageHash(pair.image1), getImageHash(pair.image2)]) results.push(compareHashes(h1, h2)) } res.json({ similarities: results }) }) // 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`) })