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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user