Rename EMAILBRO → ASPEKTER, update Coin Scout, security fixes

- Rename: Docker containers, UI, nginx, User-Agent strings
- Coin Scout: sync from COIN_SCOUT project (latest version)
- Security: Pug injection protection (validatePugSafety)
- Security: concurrent render fix (unique temp files)
- Fix: disappearing IDs input when cleared
- Audit logging: all mutations, login/logout
- Users: createdBy/updatedBy on letters
- Local image storage option
This commit is contained in:
2026-04-13 12:39:25 +05:00
parent 718821fdd6
commit d34f04e922
5 changed files with 1115 additions and 236 deletions

View File

@@ -5,7 +5,116 @@ const Database = require('better-sqlite3')
const cron = require('node-cron')
const { parse: parseHTML } = require('node-html-parser')
const { generateCoinEmail, setGradeScore } = require('./coin-writer')
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')
@@ -93,6 +202,28 @@ function initDB() {
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) {
@@ -107,8 +238,9 @@ function gradeAtLeast(grade, minGrade) {
return gradeScore(grade) >= gradeScore(minGrade)
}
// Inject gradeScore into coin-writer
// 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')
@@ -252,6 +384,16 @@ async function parseProductPage(url) {
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
@@ -260,16 +402,42 @@ async function parseProductPage(url) {
if (gradeMatch) result.grade = gradeMatch[1].trim()
}
// Try extract years from name/URL
const yearMatch = (url + ' ' + bodyText).match(/(\d{3,4})\s*[-]\s*(\d{3,4})\s*(?:год|г\.|г\b|до н)/i)
if (yearMatch) {
result.year_from = parseInt(yearMatch[1])
result.year_to = parseInt(yearMatch[2])
} else {
const singleYear = url.match(/(\d{4})_goda/)
if (singleYear) {
result.year_from = parseInt(singleYear[1])
result.year_to = result.year_from
// 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
}
}
}
}
@@ -281,6 +449,105 @@ async function parseProductPage(url) {
}
// ─── 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 'Золото'
@@ -541,7 +808,7 @@ function buildSummary(coin, details, score, reasons) {
function isCoin(name) {
const n = name.toLowerCase()
// Exclude non-coins
if (/открытк|альбом|капсул|лист[ыа]\b|подставк|футляр|лупа|пинцет|рамк|планшет|холдер|книг/.test(n)) return false
if (/открытк|альбом|капсул|лист[ыа]\b|подставк|футляр|лупа|пинцет|рамк|планшет|холдер|книг|облигаци|почтовая марк|вексел|закладн[ао]|сертификат|письмо\b|пенсионн|задолженн|ипотек|залогов/.test(n)) return false
return true
}
@@ -549,8 +816,32 @@ function isCoin(name) {
// Based on research from 12+ professional numismatic sources:
// numizmatik.ru, Forbes.ru, kp.ru, thecoinsexplorer.com, trustedcoins.com,
// NGC, PCGS guides, coinweek.com, numisdon.com, zolotoy-zapas.ru, etc.
// Silver price ~200 RUB/gram (ЦБ РФ, April 2026)
const SILVER_PRICE_PER_GRAM = 200
// 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
@@ -946,7 +1237,7 @@ async function runScan() {
const feedDetailBatch = db.transaction((items) => {
for (const item of items) {
if (!item.grade && !item.material && !item.year) continue
const yearNum = parseInt(item.year) || null
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)
}
@@ -989,8 +1280,45 @@ async function runScan() {
}
}
// Details are now extracted directly from feed data (grade, material, weight)
// No need to parse individual product pages
// 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)
@@ -1021,6 +1349,7 @@ app.get('/api/coins', (req, res) => {
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
@@ -1056,10 +1385,12 @@ app.get('/api/coins', (req, res) => {
coins = coins.filter(c => c.in_stock === 1 || c.in_stock === null)
}
// Enrich material from name if not parsed
// 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
@@ -1072,6 +1403,11 @@ app.get('/api/coins', (req, res) => {
})
}
// 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, {
@@ -1175,6 +1511,21 @@ app.get('/api/scan/status', (req, res) => {
// 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
@@ -1187,7 +1538,7 @@ app.get('/api/dashboard', (req, res) => {
const feeds = db.prepare('SELECT feed, COUNT(*) as cnt, ROUND(AVG(price),0) as avg_price FROM coins WHERE available = 1 AND price > 0 GROUP BY feed').all()
// New in last 7 days
const newThisWeek = db.prepare("SELECT COUNT(*) as cnt FROM coins WHERE first_seen >= datetime('now', '-7 days')").get().cnt
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(`
@@ -1224,6 +1575,18 @@ app.get('/api/dashboard', (req, res) => {
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
@@ -1232,6 +1595,31 @@ app.get('/api/dashboard', (req, res) => {
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
@@ -1240,7 +1628,7 @@ app.get('/api/dashboard', (req, res) => {
GROUP BY d.grade ORDER BY cnt DESC LIMIT 15
`).all()
res.json({ total, feeds, newThisWeek, priceDrops, priceRises, disappeared, materials, grades })
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 ───
@@ -1251,20 +1639,115 @@ app.get('/api/coins/:id/history', (req, res) => {
// ─── 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,
c2.feed as feed2, c2.price as price2, c2.url as url2,
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
WHERE c1.available = 1 AND c2.available = 1 AND c1.price > 0 AND c2.price > 0
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 50
`).all()
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)