- Rename: Docker containers, UI, nginx, User-Agent strings - Coin Scout: sync from COIN_SCOUT project (latest version) - Security: Pug injection protection (validatePugSafety) - Security: concurrent render fix (unique temp files) - Fix: disappearing IDs input when cleared - Audit logging: all mutations, login/logout - Users: createdBy/updatedBy on letters - Local image storage option
1787 lines
95 KiB
JavaScript
1787 lines
95 KiB
JavaScript
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 <offer>...</offer> blocks from buffer
|
||
let startIdx
|
||
while ((startIdx = buffer.indexOf('<offer ')) !== -1) {
|
||
const endIdx = buffer.indexOf('</offer>', startIdx)
|
||
if (endIdx === -1) break // incomplete, wait for more data
|
||
const offerStr = buffer.substring(startIdx + 7, endIdx) // after "<offer "
|
||
buffer = buffer.substring(endIdx + 8) // after "</offer>"
|
||
|
||
const tagEnd = offerStr.indexOf('>')
|
||
if (tagEnd === -1) continue
|
||
const attrs = offerStr.substring(0, tagEnd)
|
||
const idMatch = attrs.match(/id="([^"]*)"/)
|
||
const availMatch = attrs.match(/available="([^"]*)"/)
|
||
if (!idMatch) continue
|
||
const id = idMatch[1]
|
||
const available = availMatch ? availMatch[1] : 'true'
|
||
const body = offerStr.substring(tagEnd + 1)
|
||
|
||
const get = (tag) => {
|
||
const s = body.indexOf(`<${tag}`)
|
||
if (s === -1) return ''
|
||
const cs = body.indexOf('>', s) + 1
|
||
const e = body.indexOf(`</${tag}>`, cs)
|
||
if (e === -1) return ''
|
||
return body.substring(cs, e).trim()
|
||
}
|
||
const price = parseFloat(get('price')) || 0
|
||
const oldPrice = parseFloat(get('oldprice')) || 0
|
||
const name = get('name').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||
const url = get('url')
|
||
const image = get('picture')
|
||
const category = get('categoryId')
|
||
// Extra fields from feed (may exist)
|
||
const grade = get('condition') || ''
|
||
const material = get('material') || ''
|
||
const weight = get('weight') || get('Weight') || ''
|
||
const diameter = get('dia') || ''
|
||
const year = get('year') || ''
|
||
items.push({
|
||
id: `${feedCfg.id}_${id}`,
|
||
feed: feedCfg.id,
|
||
name,
|
||
price,
|
||
old_price: oldPrice,
|
||
url,
|
||
image,
|
||
category,
|
||
available: available === 'true' ? 1 : 0,
|
||
grade, material, weight, diameter, year,
|
||
})
|
||
}
|
||
// Keep only last incomplete chunk (trim everything before last possible <offer)
|
||
if (buffer.length > 100000) {
|
||
const lastOffer = buffer.lastIndexOf('<offer')
|
||
if (lastOffer > 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(/<Record[^>]*Code="2"[^>]*>[\s\S]*?<Buy>([\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`)
|
||
})
|