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

@@ -1,8 +1,11 @@
// ─── Coin Writer v5: literary + factual balance ───
// ─── Coin Writer v8: literary + concise ───
let gradeScore = () => -1
function setGradeScore(fn) { gradeScore = fn }
let getSilverPrice = () => 200
function setSilverPrice(fn) { getSilverPrice = fn }
function hash(str) {
let h = 0
for (let i = 0; i < str.length; i++) h = ((h << 5) - h + str.charCodeAt(i)) | 0
@@ -13,73 +16,77 @@ function q(name) { return `«${name}»` }
const PERIODS = [
{ re: /николай\s*ii|николая\s*ii/i, texts: [
'Последний российский император — его монеты неизменно возглавляют аукционные продажи.',
'Эпоха Николая II: Транссибирская магистраль, первые автомобили — и монеты, ставшие символом ушедшей империи.',
'Монеты Николая II коллекционируются по всему миру. Спрос не ослабевает десятилетиями.',
'Закат Российской империи. Его монеты — одни из самых собираемых в русской нумизматике.',
'Закат империи, эпоха золотого стандарта и Транссибирской магистрали. Монеты Николая II неизменно лидируют на аукционах русской нумизматики — от Москвы до Нью-Йорка.',
'Последний император. Его монеты — витрина русской нумизматики: безупречная чеканка, прозрачная аукционная история, спрос, не ослабевающий десятилетиями.',
'Николай II — самый коллекционируемый период Империи. Каталог Биткина знает каждый штемпель, аукционы отслеживают каждую продажу.',
]},
{ re: /пётр|петр\s*i|петра\s*i/i, from: 1682, to: 1725, texts: [
'Пётр Великий перекроил Россию — от алфавита до монетной системы.',
'Царь, построивший новую столицу на болотах. Его монеты — среди самых желанных.',
'Реформатор, открывший России путь в Европу. Коллекционный спрос стабильно высокий.',
'Пётр Великий перекроил Россию — от алфавита до монетной системы. Реформа 1700 года дала стране копейку, гривенник, полтинник и рубль. Его монеты — фундамент любой серьёзной коллекции.',
'Царь, построивший новую столицу на болотах и новую монетную систему с нуля. Среди самых желанных монет отечественной нумизматики.',
]},
{ re: /екатерин/i, from: 1762, to: 1796, texts: [
'При Екатерине II территория империи выросла на 500 тыс. кв. км. Монеты этого периода — классика.',
'Золотой век. 34 года правления и богатое нумизматическое наследие.',
'Золотой век Российской империи. При Екатерине II территория выросла на 500 тыс. кв. км, а монетное искусство достигло расцвета — изящные портретные типы, совершенная чеканка.',
]},
{ re: /александр\s*ii/i, from: 1855, to: 1881, texts: [
'Царь-Освободитель: отмена крепостного права, судебная реформа. Спрос на его монеты растёт.',
'Царь-Освободитель: отмена крепостного права, земская реформа, продажа Аляски. Монеты его эпохи переживают ренессанс коллекционного спроса.',
]},
{ re: /александр\s*iii/i, from: 1881, to: 1894, texts: [
'13 лет на троне. Единственный император, при котором Россия не вела ни одной войны. Монет — немного.',
'Тринадцать мирных лет на тронеединственный император, при котором Россия не вела ни одной войны. Короткое правление — ограниченная чеканка, каждый экземпляр на счету.',
]},
{ re: /павел/i, from: 1796, to: 1801, texts: [
'5 лет правления Павла I — и ограниченная чеканка. Каждый экземпляр нечаст.',
'Всего пять лет правления Павла I. Монеты с его характерным вензелем нечасты и неизменно востребованы.',
]},
{ re: /анна.*иоанн/i, from: 1730, to: 1740, texts: [
'Анна Иоанновна — эпоха дворцовых интриг. Монеты с особой притягательностью.',
'Анна Иоанновна — эпоха дворцовых переворотов и бироновщины. Монеты с её портретом притягивают ценителей XVIII века.',
]},
{ re: /елизавет/i, from: 1741, to: 1762, texts: [
'Дочь Петра Великого. При Елизавете расцвело монетное дело — изысканная чеканка, редкие тиражи.',
'Дочь Петра Великого. При Елизавете расцвело монетное искусство — утончённые портреты, безупречная чеканка, редкие тиражи.',
]},
{ re: /финлянд/i, texts: [
'Русская Финляндия — обособленная серия. Монеты чеканились для Великого княжества и обращались только на его территории.',
'Монеты Великого княжества Финляндского — компактная серия с преданным кругом ценителей.',
'Монеты Великого княжества Финляндского — обособленная серия, чеканившаяся для территории, где ходили свои номиналы: пенни и марки с портретами российских императоров. Компактная серия с преданным кругом ценителей.',
]},
{ re: /римск|roman|денарий|антониниан/i, texts: [
'Рим: легионы, акведуки, Колизей — и монеты, пережившие саму империю. Глобальный рынок растёт на 8-12% в год.',
'Монета цивилизации, построившей дороги, по которым ходят до сих пор.',
'Рим: легионы, акведуки, Колизей — и монеты, пережившие империю на два тысячелетия. Каждый денарий — исторический документ, побывавший в руках людей, о которых мы читаем в учебниках.',
'Монета цивилизации, построившей дороги, по которым ходят до сих пор. Мировой рынок античной нумизматики растёт на 812% ежегодно.',
]},
{ re: /греч|greek|драхм|обол|тетра/i, texts: [
'Древняя Греция: Сократ, Олимпийские игры и первые в истории монеты с портретами. Рынок растёт до 15% ежегодно.',
'Древняя Греция — родина монетного дела. Совы Афин, тетрадрахмы Александра Македонского: спрос на эти монеты не ослабевает тысячелетиями.',
]},
{ re: /боспор|пантикапей/i, texts: [
'Боспорское царство — античный Крым. Монеты Пантикапея — растущая ниша для ценителей причерноморской истории.',
'Боспорское царство — античный Крым, перекрёсток эллинского и скифского миров. Монеты Пантикапея с головой сатира узнаются с первого взгляда.',
]},
{ re: /визант|byzant/i, texts: [
'Византия просуществовала тысячу лет — дольше любой европейской империи. Доступная и перспективная античность.',
'Византия простояла тысячу лет — дольше любой европейской империи. Её солиды были мировой резервной валютой раннего Средневековья.',
]},
{ re: /осман|ottoman/i, texts: [
'Османская империя раскинулась от Дуная до Аравии. Пока недооценённый, но быстро растущий сегмент.',
]},
{ re: /сефевид|safavid/i, texts: [
'Сефевидская Персия — империя шахов, объединившая Иран. Монеты этой династии встречаются всё реже.',
'Монеты с тугрой — каллиграфическим вензелем султана — обладают особой, ни на что не похожей эстетикой. Пока недооценённый, но быстро растущий сегмент.',
]},
{ re: /смутн|владислав|лжедмитри/i, texts: [
'Смутное время — самые драматичные годы русской истории. Эти монеты — нумизматическая элита.',
'Между царствами, между эпохами. Монеты Смутного времени доступны единицам.',
]},
{ re: /медный бунт/i, texts: [
'Медный бунт 1662 года: народное восстание против обесценивания денег. Монеты-свидетели этих событий крайне редки.',
'Смутное время — самые драматичные годы русской истории. Шведская оккупация, польский королевич на русском троне. Монеты этого периода — нумизматическая элита.',
]},
{ re: /сибирск|сузун/i, texts: [
'Сибирские монеты чеканились из меди Колывано-Воскресенских заводов с примесью серебра и золота. Ходили только за Уралом.',
'Сибирская монета чеканилась из колыванской меди с природной примесью серебра и золота. Ходила только за Уралом. Обособленная серия с растущим коллекционным спросом.',
]},
{ re: /георгий победоносец/i, texts: [
'«Георгий Победоносец» — самая ликвидная монета из драгметаллов в России. Принимается в любом банке.',
'«Георгий Победоносец» — самая ликвидная монета из драгметаллов в России. Принимается в любом банке, узнаётся с первого взгляда.',
]},
{ re: /1921|1922|1923|1924|1925|1926|1927|1928|1929|1930|1931/i, materialRe: /серебро|silver/, texts: [
'Раннее СССР серебро: последний период серебра в обращении. Большинство изъято и переплавлено в 1930-хуцелевшие экземпляры особенно ценны.',
'Ранний СССР последний период, когда драгоценный металл ещё ходил в обращении. В 1930-х основной тираж изъяли и переплавили. Уцелевшие экземпляры встречаются всё реже.',
]},
{ re: /1947/i, texts: [
'1947 год — одна из загадок советской нумизматики. Монеты отчеканены, но в обращение почти не поступили и были уничтожены. Каждый уцелевший экземпляр — настоящая редкость.',
]},
{ re: /1958/i, texts: [
'1958 — монеты несостоявшейся денежной реформы. Почти весь тираж уничтожен. Находка для серьёзного коллекционера.',
]},
{ re: /полтин/i, materialRe: /серебро|silver/, texts: [
'Полтинник — крупный серебряный номинал, один из самых ликвидных в русской нумизматике. Коллекционеры собирают по годам, разновидностям штемпелей и минцмейстерам.',
]},
{ re: /рубль|рубл/i, materialRe: /серебро|silver/, from: 1700, to: 1917, texts: [
'Серебряный рубль Российской империи — «королевский» номинал русской нумизматики. Крупный модуль, детальные портреты, высокое содержание серебра. Стабильный аукционный спрос.',
]},
{ re: /талер|thaler|taler/i, texts: [
'Талер — прародитель доллара, крупная серебряная монета, ходившая по всей Европе. Ликвиден в любой точке мира.',
]},
]
@@ -87,225 +94,147 @@ function generateCoinEmail(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 age = year > 0 ? 2026 - year : 0
const price = coin.price || 0
const grade = details.grade || ''
const gs = gradeScore(grade)
const weightG = parseFloat(details.weight) || 0
const diameterMm = parseFloat(details.diameter) || 0
const isSilver = /серебро|silver/.test(mat)
const isGold = /золото|gold/.test(mat)
const isPrecious = isSilver || isGold
const h = hash(coin.id || name)
const totalAge = year < 0 ? Math.abs(year) + 2026 : age
const metalWord = isGold ? 'золото' : isSilver ? 'серебро' : ''
const metalAdj = isGold ? 'золотая' : isSilver ? 'серебряная' : ''
const p1 = []
const p2 = []
const p3 = []
const parts = []
// ══════════ P1: HOOK — литературно + факт ══════════
// ══════════ АБЗАЦ 1: КРЮЧОК — образ + исторический контекст ══════════
const hook = []
if (totalAge > 2000) {
p1.push(pick([
`${q(name)}. Этой монете более ${totalAge} лет — она старше большинства государств на карте мира. Когда её чеканили, ещё существовала Римская империя.`,
`Представьте: ${totalAge} лет назад кто-то расплатился этой монетой на рыночной площади древнего города. ${q(name)} — подлинный артефакт, переживший тысячелетия.`,
`${q(name)}. ${totalAge} лет истории в одном небольшом кружке металла. Монета, к которой прикасались люди из совершенно другого мира.`,
], h))
} else if (totalAge > 400) {
p1.push(pick([
`${q(name)}${totalAge} лет истории. Монета, которая пережила империи, войны и революции, и дошла до наших дней.`,
`${totalAge} лет назад мастер ударил штемпелем по заготовке — так появилась ${q(name)}. С тех пор мир изменился до неузнаваемости.`,
`${q(name)}. ${totalAge} лет — и монета по-прежнему существует. Каждая её потёртость — след чьей-то жизни, давно забытой историей.`,
if (age > 400) {
hook.push(pick([
`${q(name)}. Этой монете ${age} лет — она старше большинства государств на современной карте. Когда её чеканили, мастер бил штемпелем по раскалённой заготовке вручную, и каждый экземпляр получался неповторимым.`,
`${age} лет назад кто-то расплатился этой монетой. С тех пор рухнули империи, сменились языки и границы — а ${q(name)} дошла до наших дней.`,
`${q(name)} ${age} лет в одном кружке металла. Каждая потёртость на ней — след чьей-то жизни, давно забытой историей.`,
], h))
} else if (age > 200) {
p1.push(pick([
`${q(name)} — больше ${Math.floor(age / 100)} столетий истории. Монета из мира без электричества и фотографии, дошедшая до наших дней${gs >= gradeScore('VF') ? ' в достойном состоянии' : ''}.`,
`${q(name)}. ${age} лет назад она была частью чьей-то повседневности. Сегодня — коллекционная ценность.`,
`Больше ${Math.floor(age / 100)} веков назад эта монета зазвенела впервые. ${q(name)} — осколок эпохи, которую мы знаем лишь по книгам.`,
hook.push(pick([
`${q(name)}. ${year} год — мир без электричества и фотографии. Эта монета помнит то, чего уже не помнит никто из живущих.`,
`Больше двух столетий назад ${q(name)} впервые зазвенела на прилавке. Сегодня она — осколок эпохи, которую мы знаем лишь по книгам.`,
`${year} год. ${q(name)} — монета, пережившая больше двух столетий${gs >= gradeScore('XF') ? ' и дошедшая до нас в прекрасном состоянии' : ''}.`,
], h))
} else if (age > 100) {
p1.push(pick([
`${q(name)}. ${year} год — мир на пороге грандиозных перемен.${isPrecious ? ` ${isGold ? 'Золотая' : 'Серебряная'} монета, ценная и как металл, и как предмет коллекционирования.` : ''}`,
`${q(name)} — больше века истории. Монета, отчеканенная когда мир был совсем другим.${isPrecious ? ` ${isGold ? 'Золото' : 'Серебро'}.` : ''}`,
hook.push(pick([
`${q(name)}. ${year} год — мир на пороге перемен, которые изменят всё.`,
`${year} год. ${q(name)} — больше века истории. Монета из совершенно другого мира.`,
], h))
} else {
p1.push(`${q(name)}.${year ? ` ${year} год.` : ''}${isPrecious ? ` ${isGold ? 'Золотая' : 'Серебряная'} монета.` : ''}`)
hook.push(`${q(name)}.${year ? ` ${year} год.` : ''}`)
}
// Period context
// Исторический контекст
for (const period of PERIODS) {
if (!period.re.test(name)) continue
if (period.from && year && (year < period.from || year > period.to)) continue
if (period.materialRe && !period.materialRe.test(mat)) continue
p1.push(pick(period.texts, h + 7))
hook.push(pick(period.texts, h + 7))
break
}
// ══════════ CONTENT BLOCKS — independent, shuffleable ══════════
const blocks = []
parts.push(hook.join(' '))
// Block: Grade
// ══════════ АБЗАЦ 2: ТЕЛО — грейд + материал + одна деталь ══════════
const body = []
// Грейд — образно, но точно
if (gs >= gradeScore('Proof')) {
blocks.push(pick([
'Качество Proof — зеркальная поверхность, матовый рельеф. Безупречна в каждой детали.',
'Proof-чекан: полированные штемпели, идеальная заготовка — совершенство линий.',
'Proof — высшая категория качества. Зеркальное поле, матовый рельеф.',
body.push(pick([
'Качество Proof — зеркальное поле, матовый рельеф. Совершенство, созданное для ценителей, а не для кошелька.',
'Proof-чекан: полированные штемпели, безупречная заготовка. Каждая линия рисунка передана с фотографической точностью.',
], h + 10))
} else if (gs >= gradeScore('UNC')) {
blocks.push(pick([
`Сохранность UNC — не была в обращении. Полный штемпельный блеск.${age > 50 ? ` Для ${age}-летней монеты — редкость.` : ''}`,
`UNC — как в день чеканки.${age > 100 ? ` ${age} лет и ни следа износа.` : ' Ни единого следа времени.'}`,
`Не была в обращении (UNC). Оригинальный блеск, безупречный рельеф.${age > 50 ? ` Через ${age} лет — впечатляет.` : ''}`,
body.push(pick([
`UNC — монета не была в обращении. Полный штемпельный блеск, нетронутый рельеф.${age > 100 ? ` Через ${age} лет — это почти чудо: значит, кто-то берёг её с самого начала.` : ''}`,
`Сохранность UNC — как в день чеканки.${age > 50 ? ` ${age} лет, и ни единого следа износа. Такое не часто встретишь.` : ' Оригинальный штемпельный блеск.'}`,
], h + 10))
} else if (gs >= gradeScore('AU')) {
blocks.push(pick([
`AU — почти идеальное состояние. Едва заметные следы на выступающих точках.${age > 100 ? ` Для ${age}-летней монеты — отличный результат.` : ''}`,
`AU — на грани между обращением и совершенством.${age > 100 ? ` Через ${age} лет — это удача.` : ''}`,
`Almost Uncirculated. Минимальный износ.${age > 100 ? ` ${age} лет — и такое состояние.` : ' Блеск сохранён.'}`,
body.push(pick([
`AU — почти идеальное состояние. Едва заметные следы на самых выступающих точках рельефа, основной блеск сохранён.${age > 100 ? ` Для ${age}-летней монеты — отличный результат.` : ''}`,
`Almost Uncirculated — на грани между обращением и совершенством.${age > 100 ? ` ${age} лет — и такое состояние. Владельцы явно берегли эту монету.` : ' Минимальный износ, блеск на месте.'}`,
], h + 10))
} else if (gs >= gradeScore('XF')) {
blocks.push(pick([
`XF — чёткий рельеф, все детали выразительны.${age > 200 ? ` Для ${age}-летней монеты — впечатляюще. Большинство ровесников дошли в худшем виде.` : ''}`,
`Extremely Fine.${age > 200 ? ` ${age} лет а рисунок практически полный.` : ' Лёгкий износ, все элементы на месте.'}`,
`XF — высокая сохранность.${age > 200 ? ` Для монеты ${age}-летней давности — нечастый грейд.` : ''}`,
body.push(pick([
`XF — чёткий рельеф, все детали выразительны, все надписи читаются без труда.${age > 200 ? ` Для ${age}-летней монеты — впечатляюще. Большинство её ровесников дошли до нас в худшем виде.` : ''}`,
`Extremely Fine — высокая коллекционная сохранность.${age > 200 ? ` Монете ${age} лет, а рисунок практически полный. Нечастый результат.` : ' Лёгкий износ на выступающих частях, все элементы на месте.'}`,
], h + 10))
} else if (gs >= gradeScore('VF')) {
blocks.push(`Сохранность ${grade}.${age > 200 ? ` Для ${age}-летней монеты — достойно.` : ''}`)
body.push(`Сохранность ${grade} — все основные детали различимы, честный коллекционный экземпляр.${age > 200 ? ` Для ${age}-летней монеты — достойно.` : ''}`)
}
// Block: Material + melt
// Материал — конкретика, без лишних повторов слова «серебро»/«золото»
if (isSilver && weightG > 0 && price > 0) {
const melt = Math.round(weightG * 200)
const melt = Math.round(weightG * getSilverPrice())
const ratio = Math.round(melt / price * 100)
if (ratio > 100) {
blocks.push(pick([
`Серебро (${weightG}г) стоит ~${melt}₽ по курсу ЦБа монета продаётся дешевле. Такие аномалии встречаются нечасто.`,
`${weightG}г серебра${melt}₽. Стоимость металла превышает стоимость монеты.`,
`Серебра внутри на ${melt}₽ — дороже самой монеты. Арифметика на стороне покупателя.`,
body.push(pick([
`Внутри — ${weightG}г драгоценного металла на ${melt}₽ по курсу ЦБ. Монета стоит дешевле содержимого. Нумизматическую ценность вы получаете в подарок.`,
`${weightG}г при текущем курсе${melt}₽. Стоимость металла превышает цену монеты — нечастая ситуация.`,
], h + 12))
} else if (ratio > 60) {
blocks.push(pick([
`${weightG}г серебра (~${melt}₽) покрывают ${ratio}% стоимости. Надёжный фундамент.`,
`Серебро (${weightG}г, ~${melt}₽) — ${ratio}% от стоимости. Остальное — коллекционная премия.`,
], h + 12))
} else {
blocks.push(`Серебро, ${weightG}г. Драгоценный металл обеспечивает базовую поддержку стоимости.`)
body.push(`${weightG}г драгоценного металла (~${melt}₽) покрывают ${ratio}% стоимости — надёжный фундамент. Монета вряд ли подешевеет ниже стоимости содержимого.`)
} else if (weightG > 2) {
body.push(`${weightG}г${diameterMm ? `, Ø${diameterMm}мм` : ''}. Драгоценный металл обеспечивает ликвидность — такую монету всегда проще продать.`)
}
} else if (isSilver) {
blocks.push(pick([
'Серебряная монета — ценность, проверенная веками.',
'Серебро — классика нумизматики.',
'Серебро обеспечивает ликвидность — драгоценный металл всегда найдёт покупателя.',
], h + 12))
} else if (isGold && weightG > 0) {
body.push(`${weightG}г${diameterMm ? `, Ø${diameterMm}мм` : ''}. Двойная природа: нумизматический экземпляр и драгоценный металл. Одно усиливает другое.`)
} else if (isGold) {
blocks.push(pick([
'Золото — металл, который ценили во все времена и во всех цивилизациях.',
'Золотая монета — и артефакт, и ценность, проверенная тысячелетиями.',
'Золото не подвластно времени. Металл королей и императоров.',
], h + 12))
body.push('Драгоценный металл, не подвластный времени. Ценность, которая не нуждается в объяснениях.')
} else if (isSilver) {
body.push('Классика нумизматического собирательства. Благородная патина, приятная тактильность, стабильный спрос.')
}
// Block: Scarcity
// Одна деталь — тактильность, монетный двор или факт (не больше одной!)
const detailPool = []
if (age > 300 && !isGold && h % 3 === 0) detailPool.push(pick([
'Благородная патина подчёркивает рельеф — такую не подделать, она формируется десятилетиями.',
'Следы ручной чеканки делают каждый экземпляр уникальным — двух одинаковых не существует.',
], h + 18))
if (isSilver && weightG > 3 && h % 3 === 1) detailPool.push(pick([
`Приятная тяжесть ${weightG} граммов в ладони — ощущение, которое не передать фотографией.`,
`Характерный холод металла в руке${diameterMm ? `, кружок ${diameterMm}мм` : ''} — подлинность чувствуется на ощупь.`,
], h + 18))
if (isGold && h % 3 === 1) detailPool.push('Тёплый блеск, который не тускнеет веками. Особенная плотность ощущается сразу — не спутать ни с чем.')
if (/спб|петербург/i.test(name) && h % 3 === 2) detailPool.push('Чеканка Петербургского монетного двора — предприятия, основанного Петром I в 1724 году в Петропавловской крепости.')
if (/сузун/i.test(name) && h % 3 === 2) detailPool.push('Сузунский монетный двор (17631847) — единственное подобное предприятие за Уралом.')
if (isSilver && weightG > 0 && h % 3 === 2) detailPool.push(`Вес${weightG}г. В эпоху чеканки каждая монета проходила весовой контроль: отклонение от стандарта означало фальсификацию.`)
if (detailPool.length) body.push(detailPool[0])
if (body.length) parts.push(body.join(' '))
// ══════════ АБЗАЦ 3: ЗАКРЫТИЕ — почему сейчас ══════════
const closes = []
if (age > 200) {
blocks.push(pick([
`Предложение монет ${age > 300 ? 'этой эпохи' : 'этого периода'} сокращается — утраты и оседание в коллекциях делают каждый экземпляр ценнее.`,
`Число сохранившихся экземпляров может только уменьшаться. Через десять лет найти аналог будет сложнее.`,
`Каждый экземпляр, уходящий в коллекцию, сужает предложение. Процесс необратим.`,
closes.push(pick([
'Таких монет на рынке с каждым годом меньше — экземпляры уходят в постоянные коллекции и музейные фонды. Процесс необратим: их больше не чеканят.',
`Число доступных экземпляров ${age > 300 ? 'этой эпохи' : 'этого периода'} может только уменьшаться. То, что доступно сегодня, завтра может оказаться в чьей-то постоянной коллекции.`,
'Предложение сокращается с каждым годом — утраты, оседание в коллекциях, музейные фонды. Обратного хода нет.',
], h + 15))
} else if (age > 100) {
blocks.push(pick([
'Монет этого периода на рынке меньше с каждым десятилетием.',
'Доступных экземпляров всё меньше — их больше не чеканят.',
closes.push(pick([
'Столетних монет на рынке не прибавляется. С каждым десятилетием найти достойный экземпляр всё сложнее.',
'Их не чеканят уже больше века. Каждый экземпляр, ушедший в коллекцию, сужает предложение навсегда.',
], h + 15))
}
// Block: Collector appeal
if (isPrecious && gs >= gradeScore('VF')) {
blocks.push(pick([
'Драгоценный металл и хорошая сохранность — сочетание, которое ценилось во все времена.',
'Металл и состояние — два главных фактора в нумизматике. Оба на месте.',
'Сохранность и драгоценный металл — то, за чем охотятся коллекционеры.',
'Коллекционеры ценят прежде всего металл и состояние. Здесь — и то, и другое.',
], h + 16))
if (isPrecious && gs >= gradeScore('AU') && !closes.length) {
closes.push('Драгоценный металл в сочетании с высоким грейдом — формула, которая работает на протяжении всей истории нумизматики.')
}
// Block: Sensory
if (h % 3 === 0) {
if (age > 300 && !isGold) blocks.push(pick(['Благородная патина подтверждает подлинность и придаёт монете неповторимый характер.', 'Следы ручной чеканки — каждая такая монета уникальна, двух одинаковых не существует.'], h + 18))
else if (isSilver && weightG > 3) blocks.push(pick(['Приятная тяжесть настоящего серебра в ладони — ощущение, знакомое коллекционерам по всему миру.', 'Характерный холод серебра в руке. Подлинность чувствуется на ощупь.'], h + 18))
else if (isGold) blocks.push(pick(['Тёплый блеск золота, который не тускнеет веками.', 'Особая тяжесть золота в руке — ощущение, которое ни с чем не спутать.'], h + 18))
if (closes.length) parts.push(closes[0])
return parts.filter(p => p && p.trim()).join('\n\n')
}
// Block: Collecting context — почему коллекционеры собирают именно такие
if (h % 3 === 1) {
if (age > 300) {
blocks.push(pick([
'Коллекционирование античных монет — одно из старейших хобби в мире. Им увлекались Петрарка, Медичи и российские императоры.',
'Античная нумизматика — область, где каждая монета является историческим документом. Надписи, портреты, символы рассказывают о правителях и событиях.',
'Каждая античная монета чеканилась вручную — поэтому двух абсолютно одинаковых не существует. Каждый экземпляр уникален.',
], h + 19))
} else if (year >= 1700 && year <= 1917) {
blocks.push(pick([
'Монеты Российской империи — одно из самых популярных направлений в отечественной нумизматике. Спрос стабилен и предсказуем.',
'Имперские монеты привлекают коллекционеров сочетанием доступности и исторической глубины. Многие серии можно собирать по годам, монетным дворам и разновидностям.',
'Русская нумизматика XVIII-XIX веков — направление с устоявшимся рынком, каталогами и аукционной историей. Риск приобрести подделку минимален при покупке у проверенных дилеров.',
], h + 19))
} else if (/серебро|silver/.test(mat)) {
blocks.push(pick([
'Серебряные монеты — один из самых ликвидных сегментов нумизматики. Их легко оценить, легко продать, легко хранить.',
'Коллекционирование серебряных монет совмещает эстетическое удовольствие и рациональный подход к сохранению ценности.',
], h + 19))
}
}
// Block: Technical facts — интересные факты о чеканке
if (h % 3 === 2) {
if (isSilver && weightG > 0) {
blocks.push(pick([
`Вес монеты — ${weightG}г. В эпоху чеканки вес строго контролировался: отклонение означало фальсификацию и каралось по закону.`,
`${weightG}г серебра. Монетная стопа — соотношение веса монеты к номиналу — тщательно регулировалась государством.`,
`Серебро ${weightG}г. Каждая монета проходила весовой контроль — это было гарантией доверия к денежной системе.`,
], h + 20))
} else if (isGold && weightG > 0) {
blocks.push(pick([
`Вес: ${weightG}г золота. Золотые монеты чеканились с особой точностью — даже минимальное отклонение веса было недопустимо.`,
`${weightG}г золота. Золотая монета — это не просто деньги, это государственная гарантия пробы и веса.`,
], h + 20))
} else if (age > 200) {
blocks.push(pick([
'В эпоху чеканки этой монеты каждый экземпляр проходил ручной контроль качества. Брак уничтожался и переплавлялся.',
'Монетное дело в ту эпоху было одной из важнейших государственных функций — от качества монеты зависело доверие к экономике.',
], h + 20))
}
}
// Block: Gift / collection starter
if (h % 5 === 0 && age > 50) {
blocks.push(pick([
'Такая монета может стать центральным экземпляром коллекции или запоминающимся подарком для ценителя истории.',
'Монета с историей — подарок, который не теряет значения с годами. Напротив — только набирает.',
'Отличный экземпляр для начала коллекции или пополнения существующей.',
], h + 21))
}
// Block: Provenance / geography
if (h % 5 === 1) {
if (/москв|moscow|ммд/i.test(name)) blocks.push('Отчеканена на Московском монетном дворе — одном из старейших в России.')
else if (/петербург|спб|спмд|ленинград/i.test(name)) blocks.push('Чеканка Санкт-Петербургского монетного двора — предприятия, основанного Петром I в 1724 году.')
else if (/рига|riga/i.test(name)) blocks.push('Рижский монетный двор чеканил монету на протяжении нескольких столетий для разных государств.')
else if (/сузун/i.test(name)) blocks.push('Сузунский монетный двор на Алтае работал с 1763 по 1847 год — единственное подобное предприятие за Уралом.')
}
// ══════════ SHUFFLE (grade stays first, rest randomized) ══════════
const first = blocks.shift() || ''
const rest = [...blocks]
for (let i = rest.length - 1; i > 0; i--) {
const j = (h + i * 13) % (i + 1)
;[rest[i], rest[j]] = [rest[j], rest[i]]
}
const body = [first, ...rest].filter(Boolean)
const mid = Math.ceil(body.length / 2)
return [p1.join(' '), body.slice(0, mid).join(' '), body.slice(mid).join(' ')].filter(p => p && p.trim()).join('\n\n')
}
module.exports = { generateCoinEmail, setGradeScore }
module.exports = { generateCoinEmail, setGradeScore, setSilverPrice }

View File

@@ -9,6 +9,7 @@
"express": "^4.18.2",
"better-sqlite3": "^11.0.0",
"node-cron": "^3.0.3",
"node-html-parser": "^6.1.13"
"node-html-parser": "^6.1.13",
"sharp": "^0.33.0"
}
}

View File

@@ -0,0 +1,230 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Coin Scout — Как это работает</title>
<style>
@media print {
body { font-size: 11px; }
h1 { font-size: 22px; }
h2 { font-size: 16px; }
h3 { font-size: 13px; }
.no-break { page-break-inside: avoid; }
.page-break { page-break-before: always; }
a { color: #333; }
}
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Georgia, serif; max-width: 750px; margin: 40px auto; padding: 0 20px; color: #1a1a1a; line-height: 1.7; }
h1 { text-align: center; margin-bottom: 4px; font-size: 26px; }
.subtitle { text-align: center; color: #666; margin-bottom: 30px; font-size: 13px; }
h2 { color: #c45500; border-bottom: 2px solid #c45500; padding-bottom: 4px; margin-top: 32px; }
h3 { color: #333; margin-top: 20px; }
.box { background: #f8f9fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 14px 18px; margin: 14px 0; }
.box b { color: #c45500; }
.box-accent { background: #fff8f0; border-color: #c45500; }
table { width: 100%; border-collapse: collapse; margin: 10px 0 16px; font-size: 12px; }
th { background: #f5f5f5; text-align: left; padding: 6px 8px; border: 1px solid #ddd; font-weight: 600; }
td { padding: 5px 8px; border: 1px solid #ddd; }
.green { color: #1a7f37; font-weight: 600; }
.orange { color: #c45500; font-weight: 600; }
.note { color: #666; font-size: 12px; font-style: italic; }
ul { margin: 6px 0; padding-left: 20px; }
li { margin-bottom: 5px; }
.diagram { text-align: center; margin: 20px 0; font-family: monospace; font-size: 12px; background: #f8f9fa; padding: 16px; border-radius: 6px; border: 1px solid #e1e4e8; }
.number { display: inline-block; width: 28px; height: 28px; line-height: 28px; text-align: center; background: #c45500; color: white; border-radius: 50%; font-weight: 700; font-size: 14px; margin-right: 8px; vertical-align: middle; }
.step { margin: 16px 0; }
blockquote { border-left: 3px solid #c45500; margin: 12px 0; padding: 8px 16px; background: #fff8f0; font-style: italic; }
</style>
</head>
<body>
<h1>Coin Scout</h1>
<p class="subtitle">Система автоматического поиска недооценённых монет<br>в нумизматических интернет-магазинах</p>
<h2>Проблема</h2>
<p>На российском нумизматическом рынке работают десятки интернет-магазинов. В каждом — десятки тысяч позиций. Одна и та же монета в одном магазине может стоить 265₽, а в другом — 5 280₽. При этом характеристики идентичны: тот же год, тот же материал, тот же грейд сохранности.</p>
<p>Для опытного нумизмата это возможность. Но вручную отслеживать 190 000 позиций в трёх магазинах, сравнивать цены, оценивать перспективность — невозможно физически. Человек способен просмотреть 50100 монет в день. Система просматривает все 190 000 за минуты.</p>
<div class="box box-accent">
<b>Суть:</b> Coin Scout ежедневно сканирует три крупнейших нумизматических магазина России, оценивает каждую монету по 8 критериям, находит ценовые аномалии и выдаёт список лучших возможностей — монет, которые стоят дешевле, чем должны.
</div>
<h2>Источники данных</h2>
<p>Система работает с товарными фидами (XML-каталогами) трёх магазинов:</p>
<div class="no-break">
<table>
<tr><th>Магазин</th><th>Позиций</th><th>Специализация</th></tr>
<tr><td><b>numizm.at</b></td><td>~62 000</td><td>Широкий ассортимент: Россия, Европа, Азия. Много мировых монет.</td></tr>
<tr><td><b>coinsbolhov.ru</b></td><td>~37 000</td><td>Российская империя, СССР, иностранные монеты. Хорошие цены.</td></tr>
<tr><td><b>numizmat.ru</b></td><td>~9 000</td><td>Премиальный сегмент: Proof, золото, крупные номиналы.</td></tr>
</table>
</div>
<p>Суммарный охват — <b>более 108 000 уникальных монет</b>. Фиды обновляются ежедневно, система фиксирует появление новых позиций, изменения цен и исчезновение монет (вероятные продажи).</p>
<h2>Как работает система</h2>
<div class="step"><span class="number">1</span><b>Сканирование фидов.</b> Каждый день (или по кнопке) система загружает XML-каталоги всех трёх магазинов. Из каждого товара извлекаются: название, цена, старая цена, ссылка, изображение, наличие. Для надёжности используется дисковый кеш — если магазин не отвечает, берутся данные предыдущего скана.</div>
<div class="step"><span class="number">2</span><b>Парсинг деталей.</b> Из фида и со страниц товаров извлекаются характеристики: грейд (сохранность), материал, вес, диаметр, год чеканки, страна. Для монет, где фид не даёт деталей, система автоматически заходит на страницу товара и парсит таблицу характеристик.</div>
<div class="step"><span class="number">3</span><b>Обогащение данных.</b> Если магазин не указал материал или страну, система определяет их из названия: «2 копейки 1909 года СПБ» → Россия, Медь. Год извлекается с валидацией (не путая каталожные номера с датами).</div>
<div class="step"><span class="number">4</span><b>Скоринг.</b> Каждая монета получает числовую оценку от 0 до 100 баллов по 8 критериям (подробнее — в следующем разделе). Монеты сортируются по скору: чем выше — тем интереснее.</div>
<div class="step"><span class="number">5</span><b>Кросс-магазинное сравнение.</b> Система находит одинаковые монеты в разных магазинах (совпадение по названию, грейду и материалу) и показывает разницу в цене. Это позволяет купить монету там, где она дешевле.</div>
<div class="step"><span class="number">6</span><b>Отслеживание динамики.</b> При каждом скане фиксируется цена каждой монеты. Со временем накапливается история: можно увидеть, когда магазин снизил цену, и купить на просадке.</div>
<h2 class="page-break">Скоринг: 8 критериев оценки</h2>
<p>Система оценки основана на анализе 40+ профессиональных источников по нумизматике: PCGS, NGC, Forbes.ru, numisdon.com, CoinWeek, Raritetus и др.</p>
<div class="no-break">
<h3>1. Сохранность / Грейд (до 30 баллов)</h3>
<p>Главный фактор стоимости монеты. Каждый шаг грейда может увеличить цену в 250 раз. Proof = 28, UNC = 25, AU = 20, XF = 15, VF = 8 баллов. Бонус за исключительную сохранность для возраста: VF+ для монеты до 1800 года — это редкость.</p>
</div>
<div class="no-break">
<h3>2. Материал и стоимость металла (до 25 баллов)</h3>
<p>Драгоценный металл создаёт «пол» стоимости — монета не может стоить дешевле содержащегося в ней металла. Золото = 22, серебро = 14 баллов. Если монета стоит дешевле стоимости серебра внутри неё (melt value) — это +10 дополнительных баллов. Цена серебра обновляется ежедневно с сайта ЦБ РФ.</p>
</div>
<div class="no-break">
<h3>3. Возраст (до 20 баллов)</h3>
<p>Чем старше — тем меньше сохранившихся экземпляров. До н.э. = 20, 500+ лет = 18, 300+ = 14, 200+ = 10, 100+ = 6 баллов. Античные монеты показывают 815% годового роста.</p>
</div>
<div class="no-break">
<h3>4. Российские премиум-периоды (до 15 баллов)</h3>
<p>Отдельные периоды русской нумизматики обладают повышенным потенциалом: монеты 1947 и 1958 годов (не поступили в обращение), Смутное время (16101612), раннее советское серебро (19211931), монеты Николая II, Петра I, Екатерины II.</p>
</div>
<div class="no-break">
<h3>5. Мировые монеты (до 10 баллов)</h3>
<p>Бонусы за перспективные направления мировой нумизматики: Древняя Греция, Рим, Византия, Боспорское царство, Османская империя, талеры, панды, соверены.</p>
</div>
<div class="no-break">
<h3>6. Ошибки чеканки и разновидности (до 15 баллов)</h3>
<p>Монеты с браком — отдельная ценная категория. Мул / двойной аверс (+15), брак чеканки (+12), перечекан (+10), серия ЧЯП (+10), отсутствие знака монетного двора (+8). Система автоматически распознаёт браки по названию.</p>
</div>
<div class="no-break">
<h3>7. Ценовая эффективность (до 12 баллов)</h3>
<p>Бонус за выгодную цену: скидка ≥30% от старой цены (+8), AU+ дешевле 500₽ (+6), UNC до 1000₽ (+4). Чем дешевле монета хорошей сохранности — тем больше бонус.</p>
</div>
<div class="no-break">
<h3>8. Штрафы (до 20 баллов)</h3>
<p>Снижение скора за негативные факторы: копии (20), массовые юбилейные СССР (12), чищеные монеты (10), современные памятные ЦБ без драгмета (8). Система фильтрует не-монеты: облигации, марки, аксессуары.</p>
</div>
<h2 class="page-break">Ключевые механики поиска выгоды</h2>
<h3>Механика 1: Арбитраж между магазинами</h3>
<p>Одна и та же монета продаётся в разных магазинах по существенно разным ценам. Coin Scout сравнивает цены только для монет с <b>одинаковым грейдом и материалом</b> — чтобы исключить ложные совпадения.</p>
<div class="box">
<b>Реальный пример:</b> «2 копейки 1909 года СПБ», медь, VF.<br>
numizm.at — 5 280₽. coinsbolhov.ru — 265₽.<br>
Разница: <span class="orange">×20</span>. Одна и та же монета, одинаковый грейд, одинаковый материал, одинаковый тираж.
</div>
<p>Причины ценовых расхождений: разные методы ценообразования, разная оборачиваемость, разные целевые аудитории магазинов. Для покупателя это — окно возможности.</p>
<h3>Механика 2: Монеты дешевле стоимости металла</h3>
<p>Иногда серебряная монета продаётся дешевле стоимости содержащегося в ней серебра. Система рассчитывает melt value (вес × текущая цена серебра по ЦБ) и находит такие аномалии.</p>
<div class="box">
<b>Пример:</b> Монета весом 2.7г серебра. Серебро по курсу ЦБ: 188₽/г. Стоимость металла: 508₽. Цена монеты: 500₽.<br>
Вы покупаете серебро <b>дешевле рынка</b>, а нумизматическую ценность получаете в подарок.
</div>
<h3>Механика 3: Мониторинг снижений цен</h3>
<p>Система фиксирует историю цен при каждом скане. Когда магазин снижает цену — монета попадает в раздел «Снижения цен» на дашборде. Покупка на просадке — одна из базовых стратегий.</p>
<h3>Механика 4: Высокий скор при низкой цене</h3>
<p>Скоринг учитывает все факторы ценности: грейд, металл, возраст, историческую значимость, редкость. Монета со скором 60+ и ценой до 1000₽ — это потенциально недооценённый экземпляр. Система автоматически сортирует по скору и позволяет фильтровать по цене, материалу, стране и магазину.</p>
<h3>Механика 5: Обнаружение браков</h3>
<p>Монеты с ошибками чеканки (смещение, раскол штемпеля, двойной удар, перечекан, мул) — отдельная и высоко ценимая категория. Они часто продаются по обычной цене, потому что продавец не осознаёт редкость. Система автоматически распознаёт браки по названию и помечает их оранжевым тегом.</p>
<h2 class="page-break">Функции панели управления</h2>
<div class="no-break">
<h3>Горячие монеты</h3>
<p>Основной экран. Все доступные монеты, отсортированные по скору. Фильтры: максимальная цена, минимальный грейд, материал, страна, магазин, наличие, дедупликация. Для каждой монеты: подробный анализ с разбивкой по факторам, текст для рассылки, история цен.</p>
</div>
<div class="no-break">
<h3>Сравнение магазинов</h3>
<p>Таблица одинаковых монет в разных магазинах с разницей в цене. Колонки: грейд, материал, скор, цены в обоих магазинах, процент разницы. Сортировка по разнице — самые выгодные арбитражные возможности наверху.</p>
</div>
<div class="no-break">
<h3>Дашборд</h3>
<p>Общая аналитика: количество монет, динамика за неделю, текущая цена серебра (ЦБ РФ), статистика парсинга, график новых монет по дням, топ-находки недели, снижения и повышения цен, исчезнувшие монеты (вероятные продажи), распределение по материалам и грейдам.</p>
</div>
<div class="no-break">
<h3>История цен</h3>
<p>Для каждой монеты доступен график изменения цены. Позволяет увидеть тренд: монета дорожает (спрос растёт) или дешевеет (возможность для покупки).</p>
</div>
<div class="no-break">
<h3>Автоматизация</h3>
<p>Ежедневное сканирование по расписанию (настраиваемый час). Автоматическое обновление цены серебра с ЦБ РФ. Автопарсинг деталей для монет без характеристик. Дисковый кеш фидов для отказоустойчивости.</p>
</div>
<h2>Стратегия использования</h2>
<div class="step"><span class="number">1</span><b>Ежедневный мониторинг.</b> Открывайте дашборд — смотрите снижения цен и топ-находки недели. Если появилась монета с высоким скором и низкой ценой — это сигнал.</div>
<div class="step"><span class="number">2</span><b>Арбитраж.</b> Вкладка «Сравнение» — находите монеты, которые в одном магазине стоят значительно дешевле. Проверяйте грейд и фото на сайтах обоих магазинов.</div>
<div class="step"><span class="number">3</span><b>Серебро ниже melt.</b> Фильтр «Серебро» + сортировка по скору — монеты, у которых стоимость металла близка к цене или превышает её, помечены в анализе.</div>
<div class="step"><span class="number">4</span><b>Браки и разновидности.</b> Оранжевые теги «Брак», «Мул», «Перечекан» — это монеты, которые могут стоить значительно дороже, чем указано в магазине.</div>
<div class="step"><span class="number">5</span><b>Диверсификация.</b> Используйте фильтр по стране, чтобы распределить покупки между разными направлениями: Россия, Европа, античность.</div>
<div class="box box-accent" style="margin-top:30px">
<b>Ключевой принцип:</b> покупайте самую редкую монету в лучшем состоянии за минимальную цену. Coin Scout автоматизирует поиск именно таких совпадений среди 108 000+ позиций.
</div>
<h2>Технические параметры</h2>
<div class="no-break">
<table>
<tr><th>Параметр</th><th>Значение</th></tr>
<tr><td>Охват</td><td>3 магазина, 108 000+ позиций</td></tr>
<tr><td>Частота сканирования</td><td>Ежедневно (настраиваемый час) + ручной запуск</td></tr>
<tr><td>Время полного скана</td><td>35 минут</td></tr>
<tr><td>Скоринг</td><td>8 критериев, 0100 баллов</td></tr>
<tr><td>Критерии сравнения</td><td>Название + грейд + материал</td></tr>
<tr><td>Цена серебра</td><td>Автоматически с ЦБ РФ, ежедневно</td></tr>
<tr><td>Определение стран</td><td>38 паттернов, автоматически из названия</td></tr>
<tr><td>Детекция браков</td><td>6 типов: брак, мул, перечекан, разновидность, пробная, новодел</td></tr>
<tr><td>База данных</td><td>SQLite, история цен, детали, лог сканов</td></tr>
<tr><td>Развёртывание</td><td>Docker, один контейнер, порт 5180</td></tr>
</table>
</div>
<p class="note" style="margin-top:30px;text-align:center">Coin Scout · Версия апрель 2026<br>Система является аналитическим инструментом. Решение о покупке всегда принимает человек.<br>Всегда проверяйте монету лично или по фото перед покупкой.</p>
<script>
if (location.search.includes('print')) {
window.onload = () => window.print()
}
</script>
</body>
</html>

View File

@@ -49,6 +49,7 @@
.coin-meta .tag.grade-high { background: #1a4731; color: #56d364; }
.coin-meta .tag.grade-mid { background: #3d2e00; color: #e3b341; }
.coin-meta .tag.no-stock { background: #490202; color: #f85149; }
.coin-meta .tag.special { background: #3d1f00; color: #f0883e; font-weight: 600; }
.coin-price { font-size: 16px; font-weight: 600; color: #56d364; margin-top: 6px; }
.coin-price .old { font-size: 12px; color: #8b949e; text-decoration: line-through; margin-left: 6px; font-weight: 400; }
.coin-score { font-size: 11px; color: #f0883e; font-weight: 600; margin-top: 4px; }
@@ -83,8 +84,10 @@
.analysis-text { color: #c9d1d9; font-size: 13px; line-height: 1.6; white-space: pre-line; }
.analysis-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
.analysis-tags .tag { background: #21262d; padding: 3px 8px; border-radius: 4px; font-size: 11px; color: #8b949e; }
.coin-detail-btn { background: none; border: 1px solid #30363d; color: #58a6ff; font-size: 11px; padding: 2px 8px; border-radius: 4px; cursor: pointer; margin-top: 4px; }
.coin-detail-btn:hover { background: #21262d; }
.coin-detail-btn, .coin-history-btn { background: none; border: 1px solid #30363d; color: #58a6ff; font-size: 11px; padding: 2px 8px; border-radius: 4px; cursor: pointer; margin-top: 4px; }
.coin-detail-btn:hover, .coin-history-btn:hover { background: #21262d; }
.sparkline { margin-top: 4px; }
.history-chart { margin-top: 12px; }
.progress-panel { background: #1c2128; border: 1px solid #30363d; border-radius: 8px; padding: 16px; margin-bottom: 16px; display: none; }
.progress-panel.active { display: block; }
@@ -138,6 +141,11 @@
<option value="золото">Золото</option>
</select>
</label>
<label>Страна:
<select id="f-country">
<option value="">Все</option>
</select>
</label>
<label>Магазин:
<select id="f-feed">
<option value="">Все</option>
@@ -178,10 +186,69 @@
if (coin) showAnalysisModal(coin)
return
}
const hBtn = e.target.closest('.coin-history-btn')
if (hBtn) {
showPriceHistory(hBtn.dataset.coinId)
return
}
if (e.target.classList.contains('modal-overlay')) {
e.target.remove()
}
})
async function showPriceHistory(coinId) {
const coin = coinStore.get(coinId)
const overlay = document.createElement('div')
overlay.className = 'modal-overlay'
overlay.innerHTML = `<div class="modal"><button class="modal-close" onclick="this.closest('.modal-overlay').remove()">&times;</button><h3>${coin ? coin.name : 'История цен'}</h3><div class="loading">Загрузка...</div></div>`
document.body.appendChild(overlay)
try {
const res = await fetch('/api/coins/' + coinId + '/history')
const d = await res.json()
const modal = overlay.querySelector('.modal')
if (!d.history.length) {
modal.querySelector('.loading').innerHTML = '<p style="color:#8b949e">Нет данных об изменении цены</p>'
return
}
const prices = d.history.map(h => h.price)
const dates = d.history.map(h => new Date(h.recorded_at).toLocaleDateString('ru'))
const min = Math.min(...prices), max = Math.max(...prices)
const W = 560, H = 200, pad = 40
const range = max - min || 1
const points = prices.map((p, i) => {
const x = pad + (i / (prices.length - 1 || 1)) * (W - pad * 2)
const y = pad + (1 - (p - min) / range) * (H - pad * 2)
return { x, y, price: p, date: dates[i] }
})
const line = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
const dots = points.map(p => `<circle cx="${p.x}" cy="${p.y}" r="4" fill="#58a6ff" stroke="#0d1117" stroke-width="2"><title>${p.date}: ${p.price}₽</title></circle>`).join('')
const yLabels = [min, min + range / 2, max].map((v, i) => {
const y = pad + (1 - (v - min) / range) * (H - pad * 2)
return `<text x="${pad - 6}" y="${y + 4}" fill="#8b949e" font-size="11" text-anchor="end">${Math.round(v)}</text>`
}).join('')
const xLabels = [0, Math.floor(dates.length / 2), dates.length - 1].filter((v, i, a) => a.indexOf(v) === i).map(i => {
return `<text x="${points[i].x}" y="${H - 5}" fill="#8b949e" font-size="10" text-anchor="middle">${dates[i]}</text>`
}).join('')
const diff = prices[prices.length - 1] - prices[0]
const diffPct = prices[0] > 0 ? Math.round(diff / prices[0] * 100) : 0
const diffColor = diff > 0 ? '#f85149' : diff < 0 ? '#56d364' : '#8b949e'
const diffSign = diff > 0 ? '+' : ''
modal.querySelector('.loading').innerHTML = `
<div style="margin-bottom:12px">
<span style="color:#e1e4e8;font-size:18px;font-weight:600">${prices[prices.length-1]}₽</span>
<span style="color:${diffColor};margin-left:8px;font-size:14px">${diffSign}${diff}₽ (${diffSign}${diffPct}%)</span>
<span style="color:#8b949e;margin-left:8px;font-size:12px">${d.history.length} записей</span>
</div>
<svg width="${W}" height="${H}" style="display:block">
<path d="${line}" fill="none" stroke="#58a6ff" stroke-width="2"/>
${dots}
${yLabels}
${xLabels}
</svg>`
} catch (e) {
overlay.querySelector('.loading').innerHTML = `<p style="color:#f85149">Ошибка: ${e.message}</p>`
}
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const m = document.querySelector('.modal-overlay')
@@ -271,11 +338,13 @@
${c.material ? `<span class="tag ${materialClass(c.material)}">${c.material}</span>` : ''}
${c.year_from ? `<span class="tag">${c.year_from}${c.year_to && c.year_to !== c.year_from ? '' + c.year_to : ''}</span>` : ''}
${c.in_stock === 0 ? '<span class="tag no-stock">Нет в наличии</span>' : ''}
${(c.special || []).map(s => '<span class="tag special">' + s + '</span>').join('')}
</div>
<div class="coin-price">${c.price}${c.old_price && c.old_price > c.price ? `<span class="old">${c.old_price} ₽</span>` : ''}</div>
${c.score ? `<div class="coin-score">Score: ${c.score}</div>` : ''}
${c.summary ? `<div style="font-size:11px;color:#c9d1d9;margin-top:3px;line-height:1.4">${c.summary}</div>` : ''}
${c.analysis ? `<button class="coin-detail-btn" data-coin-id="${c.id}">Подробный анализ</button>` : ''}
<button class="coin-history-btn" data-coin-id="${c.id}">История цен</button>
<div class="coin-feed">${feedNames[c.feed] || c.feed}</div>
</div>
</div>`
@@ -291,6 +360,7 @@
in_stock: document.getElementById('f-stock').checked ? '1' : '0',
hide_dupes: document.getElementById('f-dupes').checked ? '1' : '0',
feed: document.getElementById('f-feed').value,
country: document.getElementById('f-country').value,
})
try {
const res = await fetch('/api/coins?' + params)
@@ -488,7 +558,7 @@
<div style="max-width:900px">
<h2 style="color:#e1e4e8;margin-bottom:16px">Дашборд</h2>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:20px">
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:20px">
<div style="background:#161b22;border:1px solid #30363d;border-radius:8px;padding:16px;text-align:center">
<div style="font-size:28px;font-weight:700;color:#56d364">${d.total.toLocaleString()}</div>
<div style="color:#8b949e;font-size:13px">Всего монет</div>
@@ -501,6 +571,24 @@
<div style="font-size:28px;font-weight:700;color:#f0883e">${d.priceDrops.length}</div>
<div style="color:#8b949e;font-size:13px">Снижения цен</div>
</div>
<div style="background:#161b22;border:1px solid #30363d;border-radius:8px;padding:16px;text-align:center">
<div style="font-size:28px;font-weight:700;color:#c9d1d9">${d.silverPrice}</div>
<div style="color:#8b949e;font-size:13px">Серебро ₽/г (ЦБ)</div>
</div>
</div>
<div style="background:#161b22;border:1px solid #30363d;border-radius:8px;padding:14px;margin-bottom:20px">
<h3 style="color:#58a6ff;margin-bottom:8px;font-size:14px">Статистика парсинга</h3>
<div style="display:flex;align-items:center;gap:16px;margin-bottom:8px">
<div style="flex:1;background:#21262d;border-radius:4px;height:8px;overflow:hidden">
<div style="background:#238636;height:100%;width:${d.parseStats.rate}%;border-radius:4px"></div>
</div>
<span style="color:#e1e4e8;font-size:13px;font-weight:600">${d.parseStats.rate}%</span>
</div>
<div style="font-size:12px;color:#8b949e">
${d.parseStats.parsed.toLocaleString()} распарсено · ${d.parseStats.unparsed.toLocaleString()} без деталей
${d.parseStats.failsByFeed.length ? ' · Без деталей: ' + d.parseStats.failsByFeed.map(f => `${feedNames[f.feed] || f.feed}: ${f.cnt.toLocaleString()}`).join(', ') : ''}
</div>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px;margin-bottom:20px">
@@ -512,6 +600,43 @@
`).join('')}
</div>
${d.dailyNew.length > 1 ? `
<div style="background:#161b22;border:1px solid #30363d;border-radius:8px;padding:14px;margin-bottom:20px">
<h3 style="color:#58a6ff;margin-bottom:10px;font-size:14px">Новые монеты по дням (14 дней)</h3>
${(() => {
const vals = d.dailyNew.map(x => x.cnt)
const max = Math.max(...vals)
const W = 560, H = 120, pad = 30
const barW = Math.max(8, Math.min(28, (W - pad * 2) / vals.length - 2))
const bars = d.dailyNew.map((x, i) => {
const bx = pad + i * ((W - pad * 2) / vals.length) + 1
const bh = max > 0 ? (x.cnt / max) * (H - pad - 10) : 0
const by = H - pad - bh
const day = x.day.substring(5)
return '<rect x="' + bx + '" y="' + by + '" width="' + barW + '" height="' + bh + '" fill="#238636" rx="2"><title>' + day + ': ' + x.cnt + '</title></rect>' +
(vals.length <= 14 ? '<text x="' + (bx + barW/2) + '" y="' + (H - 5) + '" fill="#8b949e" font-size="9" text-anchor="middle">' + day + '</text>' : '')
}).join('')
return '<svg width="' + W + '" height="' + H + '" style="display:block">' + bars + '</svg>'
})()}
</div>
` : ''}
${d.topWeek.length ? `
<h3 style="color:#56d364;margin:20px 0 10px">Топ находки за неделю</h3>
<table style="width:100%;border-collapse:collapse;font-size:13px;margin-bottom:20px">
<tr style="border-bottom:1px solid #30363d;color:#8b949e"><th style="text-align:left;padding:6px">Монета</th><th>Грейд</th><th>Материал</th><th>Цена</th><th>Магазин</th></tr>
${d.topWeek.map(c => `
<tr style="border-bottom:1px solid #21262d">
<td style="padding:6px"><a href="${c.url}" target="_blank">${c.name.substring(0,50)}</a></td>
<td style="text-align:center">${c.grade || '—'}</td>
<td style="text-align:center;color:#8b949e">${c.material || '—'}</td>
<td style="text-align:center;color:#56d364">${c.price}₽</td>
<td style="text-align:center;color:#8b949e">${feedNames[c.feed] || c.feed}</td>
</tr>
`).join('')}
</table>
` : ''}
${d.priceDrops.length ? `
<h3 style="color:#f0883e;margin:20px 0 10px">Снижения цен</h3>
<table style="width:100%;border-collapse:collapse;font-size:13px">
@@ -576,7 +701,7 @@
const content = document.getElementById('content')
content.innerHTML = '<div class="loading">Сравнение магазинов...</div>'
try {
const res = await fetch('/api/compare')
const res = await fetch('/api/compare?min_diff=50&limit=1000')
const d = await res.json()
const feedNames = { AT: 'numizm.at', KB: 'coinsbolhov.ru', RU: 'numizmat.ru' }
if (!d.comparisons.length) {
@@ -584,34 +709,129 @@
return
}
content.innerHTML = `
<div style="max-width:900px">
<h2 style="color:#e1e4e8;margin-bottom:16px">Сравнение магазинов</h2>
<p style="color:#8b949e;margin-bottom:16px;font-size:13px">Одинаковые монеты в разных магазинах, отсортированные по разнице в цене.</p>
<table style="width:100%;border-collapse:collapse;font-size:13px">
<div style="max-width:1000px">
<div style="display:flex;align-items:center;gap:16px;margin-bottom:16px">
<h2 style="color:#e1e4e8;margin:0">Сравнение магазинов</h2>
<span style="color:#8b949e;font-size:13px">${d.comparisons.length} совпадений с разницей ≥50%</span>
<a class="btn" href="/api/compare/csv?min_diff=50&limit=5000" style="margin-left:auto;text-decoration:none">Экспорт CSV</a>
<button class="btn" onclick="exportComparePdf()">Экспорт PDF</button>
</div>
<p style="color:#8b949e;margin-bottom:16px;font-size:13px">Одинаковые монеты (грейд + материал) в разных магазинах. Зелёным выделена меньшая цена.</p>
<table id="compare-table" style="width:100%;border-collapse:collapse;font-size:13px">
<tr style="border-bottom:1px solid #30363d;color:#8b949e">
<th style="text-align:left;padding:8px">Монета</th>
<th>Магазин 1</th><th>Цена 1</th>
<th>Магазин 2</th><th>Цена 2</th>
<th>Грейд</th>
<th>Материал</th>
<th>Скор</th>
<th>Фото 1</th><th>Магазин 1</th><th>Цена 1</th>
<th>Фото 2</th><th>Магазин 2</th><th>Цена 2</th>
<th>Разница</th>
<th title="Визуальное сходство фото. Низкий % может означать разные стороны (аверс/реверс)">Сходство</th>
</tr>
${d.comparisons.map(c => {
const cheaper = c.price1 <= c.price2 ? 1 : 2
return `<tr style="border-bottom:1px solid #21262d">
<td style="padding:8px;max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${c.name.substring(0,50)}</td>
<td style="padding:8px;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${c.name.substring(0,55)}</td>
<td style="text-align:center;color:#8b949e;font-size:12px">${c.grade1 || '—'}</td>
<td style="text-align:center;color:#8b949e;font-size:12px">${c.material || c.material1 || '—'}</td>
<td style="text-align:center;color:#f0883e;font-weight:600;font-size:12px">${c.score || '—'}</td>
<td style="text-align:center">${c.image1 ? `<a href="${c.url1}" target="_blank"><img src="${c.image1}" style="width:40px;height:40px;object-fit:cover;border-radius:4px" onerror="this.style.display='none'"></a>` : '—'}</td>
<td style="text-align:center"><a href="${c.url1}" target="_blank">${feedNames[c.feed1] || c.feed1}</a></td>
<td style="text-align:center;${cheaper===1?'color:#56d364;font-weight:600':''}">${c.price1}₽</td>
<td style="text-align:center">${c.image2 ? `<a href="${c.url2}" target="_blank"><img src="${c.image2}" style="width:40px;height:40px;object-fit:cover;border-radius:4px" onerror="this.style.display='none'"></a>` : '—'}</td>
<td style="text-align:center"><a href="${c.url2}" target="_blank">${feedNames[c.feed2] || c.feed2}</a></td>
<td style="text-align:center;${cheaper===2?'color:#56d364;font-weight:600':''}">${c.price2}₽</td>
<td style="text-align:center;color:#f0883e">${c.diff_pct}%</td>
<td style="text-align:center;font-size:12px" class="sim-cell" data-idx="${d.comparisons.indexOf(c)}">...</td>
</tr>`
}).join('')}
</table>
</div>`
// Store data for PDF export
window._compareData = d.comparisons
// Load visual similarity in batches
loadSimilarities(d.comparisons)
} catch (e) {
content.innerHTML = `<div class="empty"><p>Ошибка: ${e.message}</p></div>`
}
}
async function loadSimilarities(comparisons) {
const BATCH = 20
for (let i = 0; i < comparisons.length; i += BATCH) {
const batch = comparisons.slice(i, i + BATCH)
const pairs = batch.map(c => ({ image1: c.image1 || '', image2: c.image2 || '' }))
try {
const res = await fetch('/api/compare/similarity', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pairs })
})
const d = await res.json()
d.similarities.forEach((sim, j) => {
const idx = i + j
const cell = document.querySelector(`.sim-cell[data-idx="${idx}"]`)
if (!cell) return
comparisons[idx]._similarity = sim
if (sim === null) { cell.textContent = '—'; return }
const color = sim >= 90 ? '#56d364' : sim >= 70 ? '#e3b341' : '#f85149'
cell.innerHTML = `<span style="color:${color};font-weight:600">${sim}%</span>`
})
} catch (e) {
// Fill remaining with —
for (let j = 0; j < batch.length; j++) {
const cell = document.querySelector(`.sim-cell[data-idx="${i + j}"]`)
if (cell) cell.textContent = '—'
}
}
}
}
function exportComparePdf() {
const data = window._compareData
if (!data || !data.length) return
const feedNames = { AT: 'numizm.at', KB: 'coinsbolhov.ru', RU: 'numizmat.ru' }
const rows = data.map(c => {
const cheaper = c.price1 <= c.price2 ? 1 : 2
return `<tr>
<td style="padding:4px 6px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${c.name.substring(0,50)}</td>
<td style="text-align:center">${c.grade1 || '—'}</td>
<td style="text-align:center">${c.material || c.material1 || '—'}</td>
<td style="text-align:center;font-weight:600">${c.score || '—'}</td>
<td style="text-align:center">${c.image1 ? `<a href="${c.url1}"><img src="${c.image1}" style="width:30px;height:30px;object-fit:cover"></a>` : ''}</td>
<td style="text-align:center"><a href="${c.url1}">${feedNames[c.feed1] || c.feed1}</a></td>
<td style="text-align:center;${cheaper===1?'font-weight:700;color:#1a7f37':''}">${c.price1}₽</td>
<td style="text-align:center">${c.image2 ? `<a href="${c.url2}"><img src="${c.image2}" style="width:30px;height:30px;object-fit:cover"></a>` : ''}</td>
<td style="text-align:center"><a href="${c.url2}">${feedNames[c.feed2] || c.feed2}</a></td>
<td style="text-align:center;${cheaper===2?'font-weight:700;color:#1a7f37':''}">${c.price2}₽</td>
<td style="text-align:center;font-weight:600;color:#c45500">${c.diff_pct}%</td>
<td style="text-align:center">${c._similarity != null ? c._similarity + '%' : '—'}</td>
</tr>`
}).join('')
const w = window.open('', '_blank')
w.document.write(`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Coin Scout — Сравнение магазинов</title>
<style>
body { font-family: -apple-system, sans-serif; font-size: 10px; color: #1a1a1a; padding: 20px; }
h1 { font-size: 18px; margin-bottom: 4px; }
.meta { color: #666; font-size: 11px; margin-bottom: 12px; }
table { width: 100%; border-collapse: collapse; }
th { background: #f5f5f5; text-align: left; padding: 5px 6px; border: 1px solid #ddd; font-size: 10px; }
td { padding: 4px 6px; border: 1px solid #eee; font-size: 10px; }
tr:nth-child(even) { background: #fafafa; }
a { color: #0366d6; text-decoration: none; }
@media print { a { color: #0366d6; } }
</style></head><body>
<h1>Coin Scout — Сравнение магазинов</h1>
<div class="meta">${data.length} совпадений с разницей ≥50% · ${new Date().toLocaleDateString('ru')} · Зелёным выделена меньшая цена</div>
<table>
<tr><th>Монета</th><th>Грейд</th><th>Материал</th><th>Скор</th><th>Фото</th><th>Магазин 1</th><th>Цена 1</th><th>Фото</th><th>Магазин 2</th><th>Цена 2</th><th>Δ</th><th>Сход.</th></tr>
${rows}
</table>
<script>window.onload=()=>window.print()<\/script>
</body></html>`)
w.document.close()
}
function loadMethodology() {
document.getElementById('content').innerHTML = `
<div style="max-width:800px;line-height:1.7;font-size:14px">
@@ -760,8 +980,24 @@
</div>`
}
// Load country filter options
async function loadCountries() {
try {
const res = await fetch('/api/countries')
const d = await res.json()
const sel = document.getElementById('f-country')
for (const c of d.countries.slice(0, 30)) {
const opt = document.createElement('option')
opt.value = c.name
opt.textContent = `${c.name} (${c.cnt})`
sel.appendChild(opt)
}
} catch (e) {}
}
// Init
loadStats()
loadCountries()
loadCoins()
</script>
</body>

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)