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:
@@ -1,8 +1,11 @@
|
|||||||
// ─── Coin Writer v5: literary + factual balance ───
|
// ─── Coin Writer v8: literary + concise ───
|
||||||
|
|
||||||
let gradeScore = () => -1
|
let gradeScore = () => -1
|
||||||
function setGradeScore(fn) { gradeScore = fn }
|
function setGradeScore(fn) { gradeScore = fn }
|
||||||
|
|
||||||
|
let getSilverPrice = () => 200
|
||||||
|
function setSilverPrice(fn) { getSilverPrice = fn }
|
||||||
|
|
||||||
function hash(str) {
|
function hash(str) {
|
||||||
let h = 0
|
let h = 0
|
||||||
for (let i = 0; i < str.length; i++) h = ((h << 5) - h + str.charCodeAt(i)) | 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 = [
|
const PERIODS = [
|
||||||
{ re: /николай\s*ii|николая\s*ii/i, texts: [
|
{ re: /николай\s*ii|николая\s*ii/i, texts: [
|
||||||
'Последний российский император — его монеты неизменно возглавляют аукционные продажи.',
|
'Закат империи, эпоха золотого стандарта и Транссибирской магистрали. Монеты Николая II неизменно лидируют на аукционах русской нумизматики — от Москвы до Нью-Йорка.',
|
||||||
'Эпоха Николая II: Транссибирская магистраль, первые автомобили — и монеты, ставшие символом ушедшей империи.',
|
'Последний император. Его монеты — витрина русской нумизматики: безупречная чеканка, прозрачная аукционная история, спрос, не ослабевающий десятилетиями.',
|
||||||
'Монеты Николая II коллекционируются по всему миру. Спрос не ослабевает десятилетиями.',
|
'Николай II — самый коллекционируемый период Империи. Каталог Биткина знает каждый штемпель, аукционы отслеживают каждую продажу.',
|
||||||
'Закат Российской империи. Его монеты — одни из самых собираемых в русской нумизматике.',
|
|
||||||
]},
|
]},
|
||||||
{ re: /пётр|петр\s*i|петра\s*i/i, from: 1682, to: 1725, texts: [
|
{ re: /пётр|петр\s*i|петра\s*i/i, from: 1682, to: 1725, texts: [
|
||||||
'Пётр Великий перекроил Россию — от алфавита до монетной системы.',
|
'Пётр Великий перекроил Россию — от алфавита до монетной системы. Реформа 1700 года дала стране копейку, гривенник, полтинник и рубль. Его монеты — фундамент любой серьёзной коллекции.',
|
||||||
'Царь, построивший новую столицу на болотах. Его монеты — среди самых желанных.',
|
'Царь, построивший новую столицу на болотах и новую монетную систему с нуля. Среди самых желанных монет отечественной нумизматики.',
|
||||||
'Реформатор, открывший России путь в Европу. Коллекционный спрос стабильно высокий.',
|
|
||||||
]},
|
]},
|
||||||
{ re: /екатерин/i, from: 1762, to: 1796, texts: [
|
{ re: /екатерин/i, from: 1762, to: 1796, texts: [
|
||||||
'При Екатерине II территория империи выросла на 500 тыс. кв. км. Монеты этого периода — классика.',
|
'Золотой век Российской империи. При Екатерине II территория выросла на 500 тыс. кв. км, а монетное искусство достигло расцвета — изящные портретные типы, совершенная чеканка.',
|
||||||
'Золотой век. 34 года правления и богатое нумизматическое наследие.',
|
|
||||||
]},
|
]},
|
||||||
{ re: /александр\s*ii/i, from: 1855, to: 1881, texts: [
|
{ re: /александр\s*ii/i, from: 1855, to: 1881, texts: [
|
||||||
'Царь-Освободитель: отмена крепостного права, судебная реформа. Спрос на его монеты растёт.',
|
'Царь-Освободитель: отмена крепостного права, земская реформа, продажа Аляски. Монеты его эпохи переживают ренессанс коллекционного спроса.',
|
||||||
]},
|
]},
|
||||||
{ re: /александр\s*iii/i, from: 1881, to: 1894, texts: [
|
{ re: /александр\s*iii/i, from: 1881, to: 1894, texts: [
|
||||||
'13 лет на троне. Единственный император, при котором Россия не вела ни одной войны. Монет — немного.',
|
'Тринадцать мирных лет на троне — единственный император, при котором Россия не вела ни одной войны. Короткое правление — ограниченная чеканка, каждый экземпляр на счету.',
|
||||||
]},
|
]},
|
||||||
{ re: /павел/i, from: 1796, to: 1801, texts: [
|
{ re: /павел/i, from: 1796, to: 1801, texts: [
|
||||||
'5 лет правления Павла I — и ограниченная чеканка. Каждый экземпляр нечаст.',
|
'Всего пять лет правления Павла I. Монеты с его характерным вензелем нечасты и неизменно востребованы.',
|
||||||
]},
|
]},
|
||||||
{ re: /анна.*иоанн/i, from: 1730, to: 1740, texts: [
|
{ re: /анна.*иоанн/i, from: 1730, to: 1740, texts: [
|
||||||
'Анна Иоанновна — эпоха дворцовых интриг. Монеты с особой притягательностью.',
|
'Анна Иоанновна — эпоха дворцовых переворотов и бироновщины. Монеты с её портретом притягивают ценителей XVIII века.',
|
||||||
]},
|
]},
|
||||||
{ re: /елизавет/i, from: 1741, to: 1762, texts: [
|
{ re: /елизавет/i, from: 1741, to: 1762, texts: [
|
||||||
'Дочь Петра Великого. При Елизавете расцвело монетное дело — изысканная чеканка, редкие тиражи.',
|
'Дочь Петра Великого. При Елизавете расцвело монетное искусство — утончённые портреты, безупречная чеканка, редкие тиражи.',
|
||||||
]},
|
]},
|
||||||
{ re: /финлянд/i, texts: [
|
{ re: /финлянд/i, texts: [
|
||||||
'Русская Финляндия — обособленная серия. Монеты чеканились для Великого княжества и обращались только на его территории.',
|
'Монеты Великого княжества Финляндского — обособленная серия, чеканившаяся для территории, где ходили свои номиналы: пенни и марки с портретами российских императоров. Компактная серия с преданным кругом ценителей.',
|
||||||
'Монеты Великого княжества Финляндского — компактная серия с преданным кругом ценителей.',
|
|
||||||
]},
|
]},
|
||||||
{ re: /римск|roman|денарий|антониниан/i, texts: [
|
{ re: /римск|roman|денарий|антониниан/i, texts: [
|
||||||
'Рим: легионы, акведуки, Колизей — и монеты, пережившие саму империю. Глобальный рынок растёт на 8-12% в год.',
|
'Рим: легионы, акведуки, Колизей — и монеты, пережившие империю на два тысячелетия. Каждый денарий — исторический документ, побывавший в руках людей, о которых мы читаем в учебниках.',
|
||||||
'Монета цивилизации, построившей дороги, по которым ходят до сих пор.',
|
'Монета цивилизации, построившей дороги, по которым ходят до сих пор. Мировой рынок античной нумизматики растёт на 8–12% ежегодно.',
|
||||||
]},
|
]},
|
||||||
{ re: /греч|greek|драхм|обол|тетра/i, texts: [
|
{ re: /греч|greek|драхм|обол|тетра/i, texts: [
|
||||||
'Древняя Греция: Сократ, Олимпийские игры и первые в истории монеты с портретами. Рынок растёт до 15% ежегодно.',
|
'Древняя Греция — родина монетного дела. Совы Афин, тетрадрахмы Александра Македонского: спрос на эти монеты не ослабевает тысячелетиями.',
|
||||||
]},
|
]},
|
||||||
{ re: /боспор|пантикапей/i, texts: [
|
{ re: /боспор|пантикапей/i, texts: [
|
||||||
'Боспорское царство — античный Крым. Монеты Пантикапея — растущая ниша для ценителей причерноморской истории.',
|
'Боспорское царство — античный Крым, перекрёсток эллинского и скифского миров. Монеты Пантикапея с головой сатира узнаются с первого взгляда.',
|
||||||
]},
|
]},
|
||||||
{ re: /визант|byzant/i, texts: [
|
{ re: /визант|byzant/i, texts: [
|
||||||
'Византия просуществовала тысячу лет — дольше любой европейской империи. Доступная и перспективная античность.',
|
'Византия простояла тысячу лет — дольше любой европейской империи. Её солиды были мировой резервной валютой раннего Средневековья.',
|
||||||
]},
|
]},
|
||||||
{ re: /осман|ottoman/i, texts: [
|
{ re: /осман|ottoman/i, texts: [
|
||||||
'Османская империя раскинулась от Дуная до Аравии. Пока недооценённый, но быстро растущий сегмент.',
|
'Монеты с тугрой — каллиграфическим вензелем султана — обладают особой, ни на что не похожей эстетикой. Пока недооценённый, но быстро растущий сегмент.',
|
||||||
]},
|
|
||||||
{ re: /сефевид|safavid/i, texts: [
|
|
||||||
'Сефевидская Персия — империя шахов, объединившая Иран. Монеты этой династии встречаются всё реже.',
|
|
||||||
]},
|
]},
|
||||||
{ re: /смутн|владислав|лжедмитри/i, texts: [
|
{ re: /смутн|владислав|лжедмитри/i, texts: [
|
||||||
'Смутное время — самые драматичные годы русской истории. Эти монеты — нумизматическая элита.',
|
'Смутное время — самые драматичные годы русской истории. Шведская оккупация, польский королевич на русском троне. Монеты этого периода — нумизматическая элита.',
|
||||||
'Между царствами, между эпохами. Монеты Смутного времени доступны единицам.',
|
|
||||||
]},
|
|
||||||
{ re: /медный бунт/i, texts: [
|
|
||||||
'Медный бунт 1662 года: народное восстание против обесценивания денег. Монеты-свидетели этих событий крайне редки.',
|
|
||||||
]},
|
]},
|
||||||
{ re: /сибирск|сузун/i, texts: [
|
{ re: /сибирск|сузун/i, texts: [
|
||||||
'Сибирские монеты чеканились из меди Колывано-Воскресенских заводов с примесью серебра и золота. Ходили только за Уралом.',
|
'Сибирская монета — чеканилась из колыванской меди с природной примесью серебра и золота. Ходила только за Уралом. Обособленная серия с растущим коллекционным спросом.',
|
||||||
]},
|
]},
|
||||||
{ re: /георгий победоносец/i, texts: [
|
{ re: /георгий победоносец/i, texts: [
|
||||||
'«Георгий Победоносец» — самая ликвидная монета из драгметаллов в России. Принимается в любом банке.',
|
'«Георгий Победоносец» — самая ликвидная монета из драгметаллов в России. Принимается в любом банке, узнаётся с первого взгляда.',
|
||||||
]},
|
]},
|
||||||
{ re: /1921|1922|1923|1924|1925|1926|1927|1928|1929|1930|1931/i, materialRe: /серебро|silver/, 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 name = coin.name || ''
|
||||||
const mat = (details.material || '').toLowerCase()
|
const mat = (details.material || '').toLowerCase()
|
||||||
const year = details.year_from || 0
|
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 price = coin.price || 0
|
||||||
const grade = details.grade || ''
|
const grade = details.grade || ''
|
||||||
const gs = gradeScore(grade)
|
const gs = gradeScore(grade)
|
||||||
const weightG = parseFloat(details.weight) || 0
|
const weightG = parseFloat(details.weight) || 0
|
||||||
|
const diameterMm = parseFloat(details.diameter) || 0
|
||||||
const isSilver = /серебро|silver/.test(mat)
|
const isSilver = /серебро|silver/.test(mat)
|
||||||
const isGold = /золото|gold/.test(mat)
|
const isGold = /золото|gold/.test(mat)
|
||||||
const isPrecious = isSilver || isGold
|
const isPrecious = isSilver || isGold
|
||||||
const h = hash(coin.id || name)
|
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 parts = []
|
||||||
const p2 = []
|
|
||||||
const p3 = []
|
|
||||||
|
|
||||||
// ══════════ P1: HOOK — литературно + факт ══════════
|
// ══════════ АБЗАЦ 1: КРЮЧОК — образ + исторический контекст ══════════
|
||||||
|
const hook = []
|
||||||
|
|
||||||
if (totalAge > 2000) {
|
if (age > 400) {
|
||||||
p1.push(pick([
|
hook.push(pick([
|
||||||
`${q(name)}. Этой монете более ${totalAge} лет — она старше большинства государств на карте мира. Когда её чеканили, ещё существовала Римская империя.`,
|
`${q(name)}. Этой монете ${age} лет — она старше большинства государств на современной карте. Когда её чеканили, мастер бил штемпелем по раскалённой заготовке вручную, и каждый экземпляр получался неповторимым.`,
|
||||||
`Представьте: ${totalAge} лет назад кто-то расплатился этой монетой на рыночной площади древнего города. ${q(name)} — подлинный артефакт, переживший тысячелетия.`,
|
`${age} лет назад кто-то расплатился этой монетой. С тех пор рухнули империи, сменились языки и границы — а ${q(name)} дошла до наших дней.`,
|
||||||
`${q(name)}. ${totalAge} лет истории в одном небольшом кружке металла. Монета, к которой прикасались люди из совершенно другого мира.`,
|
`${q(name)} — ${age} лет в одном кружке металла. Каждая потёртость на ней — след чьей-то жизни, давно забытой историей.`,
|
||||||
], h))
|
|
||||||
} else if (totalAge > 400) {
|
|
||||||
p1.push(pick([
|
|
||||||
`${q(name)} — ${totalAge} лет истории. Монета, которая пережила империи, войны и революции, и дошла до наших дней.`,
|
|
||||||
`${totalAge} лет назад мастер ударил штемпелем по заготовке — так появилась ${q(name)}. С тех пор мир изменился до неузнаваемости.`,
|
|
||||||
`${q(name)}. ${totalAge} лет — и монета по-прежнему существует. Каждая её потёртость — след чьей-то жизни, давно забытой историей.`,
|
|
||||||
], h))
|
], h))
|
||||||
} else if (age > 200) {
|
} else if (age > 200) {
|
||||||
p1.push(pick([
|
hook.push(pick([
|
||||||
`${q(name)} — больше ${Math.floor(age / 100)} столетий истории. Монета из мира без электричества и фотографии, дошедшая до наших дней${gs >= gradeScore('VF') ? ' в достойном состоянии' : ''}.`,
|
`${q(name)}. ${year} год — мир без электричества и фотографии. Эта монета помнит то, чего уже не помнит никто из живущих.`,
|
||||||
`${q(name)}. ${age} лет назад она была частью чьей-то повседневности. Сегодня — коллекционная ценность.`,
|
`Больше двух столетий назад ${q(name)} впервые зазвенела на прилавке. Сегодня она — осколок эпохи, которую мы знаем лишь по книгам.`,
|
||||||
`Больше ${Math.floor(age / 100)} веков назад эта монета зазвенела впервые. ${q(name)} — осколок эпохи, которую мы знаем лишь по книгам.`,
|
`${year} год. ${q(name)} — монета, пережившая больше двух столетий${gs >= gradeScore('XF') ? ' и дошедшая до нас в прекрасном состоянии' : ''}.`,
|
||||||
], h))
|
], h))
|
||||||
} else if (age > 100) {
|
} else if (age > 100) {
|
||||||
p1.push(pick([
|
hook.push(pick([
|
||||||
`${q(name)}. ${year} год — мир на пороге грандиозных перемен.${isPrecious ? ` ${isGold ? 'Золотая' : 'Серебряная'} монета, ценная и как металл, и как предмет коллекционирования.` : ''}`,
|
`${q(name)}. ${year} год — мир на пороге перемен, которые изменят всё.`,
|
||||||
`${q(name)} — больше века истории. Монета, отчеканенная когда мир был совсем другим.${isPrecious ? ` ${isGold ? 'Золото' : 'Серебро'}.` : ''}`,
|
`${year} год. ${q(name)} — больше века истории. Монета из совершенно другого мира.`,
|
||||||
], h))
|
], h))
|
||||||
} else {
|
} else {
|
||||||
p1.push(`${q(name)}.${year ? ` ${year} год.` : ''}${isPrecious ? ` ${isGold ? 'Золотая' : 'Серебряная'} монета.` : ''}`)
|
hook.push(`${q(name)}.${year ? ` ${year} год.` : ''}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Period context
|
// Исторический контекст
|
||||||
for (const period of PERIODS) {
|
for (const period of PERIODS) {
|
||||||
if (!period.re.test(name)) continue
|
if (!period.re.test(name)) continue
|
||||||
if (period.from && year && (year < period.from || year > period.to)) continue
|
if (period.from && year && (year < period.from || year > period.to)) continue
|
||||||
if (period.materialRe && !period.materialRe.test(mat)) continue
|
if (period.materialRe && !period.materialRe.test(mat)) continue
|
||||||
p1.push(pick(period.texts, h + 7))
|
hook.push(pick(period.texts, h + 7))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// ══════════ CONTENT BLOCKS — independent, shuffleable ══════════
|
parts.push(hook.join(' '))
|
||||||
const blocks = []
|
|
||||||
|
|
||||||
// Block: Grade
|
// ══════════ АБЗАЦ 2: ТЕЛО — грейд + материал + одна деталь ══════════
|
||||||
|
const body = []
|
||||||
|
|
||||||
|
// Грейд — образно, но точно
|
||||||
if (gs >= gradeScore('Proof')) {
|
if (gs >= gradeScore('Proof')) {
|
||||||
blocks.push(pick([
|
body.push(pick([
|
||||||
'Качество Proof — зеркальная поверхность, матовый рельеф. Безупречна в каждой детали.',
|
'Качество Proof — зеркальное поле, матовый рельеф. Совершенство, созданное для ценителей, а не для кошелька.',
|
||||||
'Proof-чекан: полированные штемпели, идеальная заготовка — совершенство линий.',
|
'Proof-чекан: полированные штемпели, безупречная заготовка. Каждая линия рисунка передана с фотографической точностью.',
|
||||||
'Proof — высшая категория качества. Зеркальное поле, матовый рельеф.',
|
|
||||||
], h + 10))
|
], h + 10))
|
||||||
} else if (gs >= gradeScore('UNC')) {
|
} else if (gs >= gradeScore('UNC')) {
|
||||||
blocks.push(pick([
|
body.push(pick([
|
||||||
`Сохранность UNC — не была в обращении. Полный штемпельный блеск.${age > 50 ? ` Для ${age}-летней монеты — редкость.` : ''}`,
|
`UNC — монета не была в обращении. Полный штемпельный блеск, нетронутый рельеф.${age > 100 ? ` Через ${age} лет — это почти чудо: значит, кто-то берёг её с самого начала.` : ''}`,
|
||||||
`UNC — как в день чеканки.${age > 100 ? ` ${age} лет — и ни следа износа.` : ' Ни единого следа времени.'}`,
|
`Сохранность UNC — как в день чеканки.${age > 50 ? ` ${age} лет, и ни единого следа износа. Такое не часто встретишь.` : ' Оригинальный штемпельный блеск.'}`,
|
||||||
`Не была в обращении (UNC). Оригинальный блеск, безупречный рельеф.${age > 50 ? ` Через ${age} лет — впечатляет.` : ''}`,
|
|
||||||
], h + 10))
|
], h + 10))
|
||||||
} else if (gs >= gradeScore('AU')) {
|
} else if (gs >= gradeScore('AU')) {
|
||||||
blocks.push(pick([
|
body.push(pick([
|
||||||
`AU — почти идеальное состояние. Едва заметные следы на выступающих точках.${age > 100 ? ` Для ${age}-летней монеты — отличный результат.` : ''}`,
|
`AU — почти идеальное состояние. Едва заметные следы на самых выступающих точках рельефа, основной блеск сохранён.${age > 100 ? ` Для ${age}-летней монеты — отличный результат.` : ''}`,
|
||||||
`AU — на грани между обращением и совершенством.${age > 100 ? ` Через ${age} лет — это удача.` : ''}`,
|
`Almost Uncirculated — на грани между обращением и совершенством.${age > 100 ? ` ${age} лет — и такое состояние. Владельцы явно берегли эту монету.` : ' Минимальный износ, блеск на месте.'}`,
|
||||||
`Almost Uncirculated. Минимальный износ.${age > 100 ? ` ${age} лет — и такое состояние.` : ' Блеск сохранён.'}`,
|
|
||||||
], h + 10))
|
], h + 10))
|
||||||
} else if (gs >= gradeScore('XF')) {
|
} else if (gs >= gradeScore('XF')) {
|
||||||
blocks.push(pick([
|
body.push(pick([
|
||||||
`XF — чёткий рельеф, все детали выразительны.${age > 200 ? ` Для ${age}-летней монеты — впечатляюще. Большинство ровесников дошли в худшем виде.` : ''}`,
|
`XF — чёткий рельеф, все детали выразительны, все надписи читаются без труда.${age > 200 ? ` Для ${age}-летней монеты — впечатляюще. Большинство её ровесников дошли до нас в худшем виде.` : ''}`,
|
||||||
`Extremely Fine.${age > 200 ? ` ${age} лет — а рисунок практически полный.` : ' Лёгкий износ, все элементы на месте.'}`,
|
`Extremely Fine — высокая коллекционная сохранность.${age > 200 ? ` Монете ${age} лет, а рисунок практически полный. Нечастый результат.` : ' Лёгкий износ на выступающих частях, все элементы на месте.'}`,
|
||||||
`XF — высокая сохранность.${age > 200 ? ` Для монеты ${age}-летней давности — нечастый грейд.` : ''}`,
|
|
||||||
], h + 10))
|
], h + 10))
|
||||||
} else if (gs >= gradeScore('VF')) {
|
} 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) {
|
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)
|
const ratio = Math.round(melt / price * 100)
|
||||||
if (ratio > 100) {
|
if (ratio > 100) {
|
||||||
blocks.push(pick([
|
body.push(pick([
|
||||||
`Серебро (${weightG}г) стоит ~${melt}₽ по курсу ЦБ — а монета продаётся дешевле. Такие аномалии встречаются нечасто.`,
|
`Внутри — ${weightG}г драгоценного металла на ${melt}₽ по курсу ЦБ. Монета стоит дешевле содержимого. Нумизматическую ценность вы получаете в подарок.`,
|
||||||
`${weightG}г серебра ≈ ${melt}₽. Стоимость металла превышает стоимость монеты.`,
|
`${weightG}г при текущем курсе ≈ ${melt}₽. Стоимость металла превышает цену монеты — нечастая ситуация.`,
|
||||||
`Серебра внутри на ${melt}₽ — дороже самой монеты. Арифметика на стороне покупателя.`,
|
|
||||||
], h + 12))
|
], h + 12))
|
||||||
} else if (ratio > 60) {
|
} else if (ratio > 60) {
|
||||||
blocks.push(pick([
|
body.push(`${weightG}г драгоценного металла (~${melt}₽) покрывают ${ratio}% стоимости — надёжный фундамент. Монета вряд ли подешевеет ниже стоимости содержимого.`)
|
||||||
`${weightG}г серебра (~${melt}₽) покрывают ${ratio}% стоимости. Надёжный фундамент.`,
|
} else if (weightG > 2) {
|
||||||
`Серебро (${weightG}г, ~${melt}₽) — ${ratio}% от стоимости. Остальное — коллекционная премия.`,
|
body.push(`${weightG}г${diameterMm ? `, Ø${diameterMm}мм` : ''}. Драгоценный металл обеспечивает ликвидность — такую монету всегда проще продать.`)
|
||||||
], h + 12))
|
|
||||||
} else {
|
|
||||||
blocks.push(`Серебро, ${weightG}г. Драгоценный металл обеспечивает базовую поддержку стоимости.`)
|
|
||||||
}
|
}
|
||||||
} else if (isSilver) {
|
} else if (isGold && weightG > 0) {
|
||||||
blocks.push(pick([
|
body.push(`${weightG}г${diameterMm ? `, Ø${diameterMm}мм` : ''}. Двойная природа: нумизматический экземпляр и драгоценный металл. Одно усиливает другое.`)
|
||||||
'Серебряная монета — ценность, проверенная веками.',
|
|
||||||
'Серебро — классика нумизматики.',
|
|
||||||
'Серебро обеспечивает ликвидность — драгоценный металл всегда найдёт покупателя.',
|
|
||||||
], h + 12))
|
|
||||||
} else if (isGold) {
|
} else if (isGold) {
|
||||||
blocks.push(pick([
|
body.push('Драгоценный металл, не подвластный времени. Ценность, которая не нуждается в объяснениях.')
|
||||||
'Золото — металл, который ценили во все времена и во всех цивилизациях.',
|
} else if (isSilver) {
|
||||||
'Золотая монета — и артефакт, и ценность, проверенная тысячелетиями.',
|
body.push('Классика нумизматического собирательства. Благородная патина, приятная тактильность, стабильный спрос.')
|
||||||
'Золото не подвластно времени. Металл королей и императоров.',
|
|
||||||
], h + 12))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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('Сузунский монетный двор (1763–1847) — единственное подобное предприятие за Уралом.')
|
||||||
|
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) {
|
if (age > 200) {
|
||||||
blocks.push(pick([
|
closes.push(pick([
|
||||||
`Предложение монет ${age > 300 ? 'этой эпохи' : 'этого периода'} сокращается — утраты и оседание в коллекциях делают каждый экземпляр ценнее.`,
|
'Таких монет на рынке с каждым годом меньше — экземпляры уходят в постоянные коллекции и музейные фонды. Процесс необратим: их больше не чеканят.',
|
||||||
`Число сохранившихся экземпляров может только уменьшаться. Через десять лет найти аналог будет сложнее.`,
|
`Число доступных экземпляров ${age > 300 ? 'этой эпохи' : 'этого периода'} может только уменьшаться. То, что доступно сегодня, завтра может оказаться в чьей-то постоянной коллекции.`,
|
||||||
`Каждый экземпляр, уходящий в коллекцию, сужает предложение. Процесс необратим.`,
|
'Предложение сокращается с каждым годом — утраты, оседание в коллекциях, музейные фонды. Обратного хода нет.',
|
||||||
], h + 15))
|
], h + 15))
|
||||||
} else if (age > 100) {
|
} else if (age > 100) {
|
||||||
blocks.push(pick([
|
closes.push(pick([
|
||||||
'Монет этого периода на рынке меньше с каждым десятилетием.',
|
'Столетних монет на рынке не прибавляется. С каждым десятилетием найти достойный экземпляр всё сложнее.',
|
||||||
'Доступных экземпляров всё меньше — их больше не чеканят.',
|
'Их не чеканят уже больше века. Каждый экземпляр, ушедший в коллекцию, сужает предложение навсегда.',
|
||||||
], h + 15))
|
], h + 15))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block: Collector appeal
|
if (isPrecious && gs >= gradeScore('AU') && !closes.length) {
|
||||||
if (isPrecious && gs >= gradeScore('VF')) {
|
closes.push('Драгоценный металл в сочетании с высоким грейдом — формула, которая работает на протяжении всей истории нумизматики.')
|
||||||
blocks.push(pick([
|
|
||||||
'Драгоценный металл и хорошая сохранность — сочетание, которое ценилось во все времена.',
|
|
||||||
'Металл и состояние — два главных фактора в нумизматике. Оба на месте.',
|
|
||||||
'Сохранность и драгоценный металл — то, за чем охотятся коллекционеры.',
|
|
||||||
'Коллекционеры ценят прежде всего металл и состояние. Здесь — и то, и другое.',
|
|
||||||
], h + 16))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block: Sensory
|
if (closes.length) parts.push(closes[0])
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block: Collecting context — почему коллекционеры собирают именно такие
|
return parts.filter(p => p && p.trim()).join('\n\n')
|
||||||
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 }
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"better-sqlite3": "^11.0.0",
|
"better-sqlite3": "^11.0.0",
|
||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
"node-html-parser": "^6.1.13"
|
"node-html-parser": "^6.1.13",
|
||||||
|
"sharp": "^0.33.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
230
coin-scout/public/about.html
Normal file
230
coin-scout/public/about.html
Normal 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 позиций в трёх магазинах, сравнивать цены, оценивать перспективность — невозможно физически. Человек способен просмотреть 50–100 монет в день. Система просматривает все 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>Главный фактор стоимости монеты. Каждый шаг грейда может увеличить цену в 2–50 раз. 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 баллов. Античные монеты показывают 8–15% годового роста.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="no-break">
|
||||||
|
<h3>4. Российские премиум-периоды (до 15 баллов)</h3>
|
||||||
|
<p>Отдельные периоды русской нумизматики обладают повышенным потенциалом: монеты 1947 и 1958 годов (не поступили в обращение), Смутное время (1610–1612), раннее советское серебро (1921–1931), монеты Николая 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>3–5 минут</td></tr>
|
||||||
|
<tr><td>Скоринг</td><td>8 критериев, 0–100 баллов</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>
|
||||||
@@ -49,6 +49,7 @@
|
|||||||
.coin-meta .tag.grade-high { background: #1a4731; color: #56d364; }
|
.coin-meta .tag.grade-high { background: #1a4731; color: #56d364; }
|
||||||
.coin-meta .tag.grade-mid { background: #3d2e00; color: #e3b341; }
|
.coin-meta .tag.grade-mid { background: #3d2e00; color: #e3b341; }
|
||||||
.coin-meta .tag.no-stock { background: #490202; color: #f85149; }
|
.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 { 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-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; }
|
.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-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 { 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; }
|
.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, .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 { background: #21262d; }
|
.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 { background: #1c2128; border: 1px solid #30363d; border-radius: 8px; padding: 16px; margin-bottom: 16px; display: none; }
|
||||||
.progress-panel.active { display: block; }
|
.progress-panel.active { display: block; }
|
||||||
@@ -138,6 +141,11 @@
|
|||||||
<option value="золото">Золото</option>
|
<option value="золото">Золото</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label>Страна:
|
||||||
|
<select id="f-country">
|
||||||
|
<option value="">Все</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label>Магазин:
|
<label>Магазин:
|
||||||
<select id="f-feed">
|
<select id="f-feed">
|
||||||
<option value="">Все</option>
|
<option value="">Все</option>
|
||||||
@@ -178,10 +186,69 @@
|
|||||||
if (coin) showAnalysisModal(coin)
|
if (coin) showAnalysisModal(coin)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const hBtn = e.target.closest('.coin-history-btn')
|
||||||
|
if (hBtn) {
|
||||||
|
showPriceHistory(hBtn.dataset.coinId)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (e.target.classList.contains('modal-overlay')) {
|
if (e.target.classList.contains('modal-overlay')) {
|
||||||
e.target.remove()
|
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()">×</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) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
const m = document.querySelector('.modal-overlay')
|
const m = document.querySelector('.modal-overlay')
|
||||||
@@ -271,11 +338,13 @@
|
|||||||
${c.material ? `<span class="tag ${materialClass(c.material)}">${c.material}</span>` : ''}
|
${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.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.in_stock === 0 ? '<span class="tag no-stock">Нет в наличии</span>' : ''}
|
||||||
|
${(c.special || []).map(s => '<span class="tag special">' + s + '</span>').join('')}
|
||||||
</div>
|
</div>
|
||||||
<div class="coin-price">${c.price} ₽${c.old_price && c.old_price > c.price ? `<span class="old">${c.old_price} ₽</span>` : ''}</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.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.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>` : ''}
|
${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 class="coin-feed">${feedNames[c.feed] || c.feed}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`
|
</div>`
|
||||||
@@ -291,6 +360,7 @@
|
|||||||
in_stock: document.getElementById('f-stock').checked ? '1' : '0',
|
in_stock: document.getElementById('f-stock').checked ? '1' : '0',
|
||||||
hide_dupes: document.getElementById('f-dupes').checked ? '1' : '0',
|
hide_dupes: document.getElementById('f-dupes').checked ? '1' : '0',
|
||||||
feed: document.getElementById('f-feed').value,
|
feed: document.getElementById('f-feed').value,
|
||||||
|
country: document.getElementById('f-country').value,
|
||||||
})
|
})
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/coins?' + params)
|
const res = await fetch('/api/coins?' + params)
|
||||||
@@ -488,7 +558,7 @@
|
|||||||
<div style="max-width:900px">
|
<div style="max-width:900px">
|
||||||
<h2 style="color:#e1e4e8;margin-bottom:16px">Дашборд</h2>
|
<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="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="font-size:28px;font-weight:700;color:#56d364">${d.total.toLocaleString()}</div>
|
||||||
<div style="color:#8b949e;font-size:13px">Всего монет</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="font-size:28px;font-weight:700;color:#f0883e">${d.priceDrops.length}</div>
|
||||||
<div style="color:#8b949e;font-size:13px">Снижения цен</div>
|
<div style="color:#8b949e;font-size:13px">Снижения цен</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px;margin-bottom:20px">
|
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px;margin-bottom:20px">
|
||||||
@@ -512,6 +600,43 @@
|
|||||||
`).join('')}
|
`).join('')}
|
||||||
</div>
|
</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 ? `
|
${d.priceDrops.length ? `
|
||||||
<h3 style="color:#f0883e;margin:20px 0 10px">Снижения цен</h3>
|
<h3 style="color:#f0883e;margin:20px 0 10px">Снижения цен</h3>
|
||||||
<table style="width:100%;border-collapse:collapse;font-size:13px">
|
<table style="width:100%;border-collapse:collapse;font-size:13px">
|
||||||
@@ -576,7 +701,7 @@
|
|||||||
const content = document.getElementById('content')
|
const content = document.getElementById('content')
|
||||||
content.innerHTML = '<div class="loading">Сравнение магазинов...</div>'
|
content.innerHTML = '<div class="loading">Сравнение магазинов...</div>'
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/compare')
|
const res = await fetch('/api/compare?min_diff=50&limit=1000')
|
||||||
const d = await res.json()
|
const d = await res.json()
|
||||||
const feedNames = { AT: 'numizm.at', KB: 'coinsbolhov.ru', RU: 'numizmat.ru' }
|
const feedNames = { AT: 'numizm.at', KB: 'coinsbolhov.ru', RU: 'numizmat.ru' }
|
||||||
if (!d.comparisons.length) {
|
if (!d.comparisons.length) {
|
||||||
@@ -584,34 +709,129 @@
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
content.innerHTML = `
|
content.innerHTML = `
|
||||||
<div style="max-width:900px">
|
<div style="max-width:1000px">
|
||||||
<h2 style="color:#e1e4e8;margin-bottom:16px">Сравнение магазинов</h2>
|
<div style="display:flex;align-items:center;gap:16px;margin-bottom:16px">
|
||||||
<p style="color:#8b949e;margin-bottom:16px;font-size:13px">Одинаковые монеты в разных магазинах, отсортированные по разнице в цене.</p>
|
<h2 style="color:#e1e4e8;margin:0">Сравнение магазинов</h2>
|
||||||
<table style="width:100%;border-collapse:collapse;font-size:13px">
|
<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">
|
<tr style="border-bottom:1px solid #30363d;color:#8b949e">
|
||||||
<th style="text-align:left;padding:8px">Монета</th>
|
<th style="text-align:left;padding:8px">Монета</th>
|
||||||
<th>Магазин 1</th><th>Цена 1</th>
|
<th>Грейд</th>
|
||||||
<th>Магазин 2</th><th>Цена 2</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>Разница</th>
|
||||||
|
<th title="Визуальное сходство фото. Низкий % может означать разные стороны (аверс/реверс)">Сходство</th>
|
||||||
</tr>
|
</tr>
|
||||||
${d.comparisons.map(c => {
|
${d.comparisons.map(c => {
|
||||||
const cheaper = c.price1 <= c.price2 ? 1 : 2
|
const cheaper = c.price1 <= c.price2 ? 1 : 2
|
||||||
return `<tr style="border-bottom:1px solid #21262d">
|
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"><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;${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"><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;${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;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>`
|
</tr>`
|
||||||
}).join('')}
|
}).join('')}
|
||||||
</table>
|
</table>
|
||||||
</div>`
|
</div>`
|
||||||
|
// Store data for PDF export
|
||||||
|
window._compareData = d.comparisons
|
||||||
|
// Load visual similarity in batches
|
||||||
|
loadSimilarities(d.comparisons)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
content.innerHTML = `<div class="empty"><p>Ошибка: ${e.message}</p></div>`
|
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() {
|
function loadMethodology() {
|
||||||
document.getElementById('content').innerHTML = `
|
document.getElementById('content').innerHTML = `
|
||||||
<div style="max-width:800px;line-height:1.7;font-size:14px">
|
<div style="max-width:800px;line-height:1.7;font-size:14px">
|
||||||
@@ -760,8 +980,24 @@
|
|||||||
</div>`
|
</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
|
// Init
|
||||||
loadStats()
|
loadStats()
|
||||||
|
loadCountries()
|
||||||
loadCoins()
|
loadCoins()
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -5,7 +5,116 @@ const Database = require('better-sqlite3')
|
|||||||
const cron = require('node-cron')
|
const cron = require('node-cron')
|
||||||
const { parse: parseHTML } = require('node-html-parser')
|
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 PORT = 5180
|
||||||
const DB_PATH = path.resolve(__dirname, 'data', 'coins.db')
|
const DB_PATH = path.resolve(__dirname, 'data', 'coins.db')
|
||||||
@@ -93,6 +202,28 @@ function initDB() {
|
|||||||
|
|
||||||
const db = 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 ───
|
// ─── Grade scoring ───
|
||||||
const GRADE_ORDER = ['G', 'AG', 'VG', 'F', 'VF', 'XF', 'EF', 'AU', 'UNC', 'BU', 'Proof']
|
const GRADE_ORDER = ['G', 'AG', 'VG', 'F', 'VF', 'XF', 'EF', 'AU', 'UNC', 'BU', 'Proof']
|
||||||
function gradeScore(grade) {
|
function gradeScore(grade) {
|
||||||
@@ -107,8 +238,9 @@ function gradeAtLeast(grade, minGrade) {
|
|||||||
return gradeScore(grade) >= gradeScore(minGrade)
|
return gradeScore(grade) >= gradeScore(minGrade)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inject gradeScore into coin-writer
|
// Inject gradeScore and silver price into coin-writer
|
||||||
setGradeScore(gradeScore)
|
setGradeScore(gradeScore)
|
||||||
|
setSilverPrice(() => SILVER_PRICE_PER_GRAM)
|
||||||
|
|
||||||
// ─── Feed file cache (disk) ───
|
// ─── Feed file cache (disk) ───
|
||||||
const FEED_CACHE_DIR = path.resolve(__dirname, 'data', 'feed-cache')
|
const FEED_CACHE_DIR = path.resolve(__dirname, 'data', 'feed-cache')
|
||||||
@@ -252,6 +384,16 @@ async function parseProductPage(url) {
|
|||||||
if (/страна|country/.test(text)) {
|
if (/страна|country/.test(text)) {
|
||||||
result.country = rawVal
|
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
|
// 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()
|
if (gradeMatch) result.grade = gradeMatch[1].trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try extract years from name/URL
|
// Try extract years — prefer table value, then name, then URL
|
||||||
const yearMatch = (url + ' ' + bodyText).match(/(\d{3,4})\s*[-–]\s*(\d{3,4})\s*(?:год|г\.|г\b|до н)/i)
|
if (!result.year_from) {
|
||||||
if (yearMatch) {
|
// From coin name: "2 копейки 1909 года" or "1 солид 1621 года" or "1610-1612 года"
|
||||||
result.year_from = parseInt(yearMatch[1])
|
const nameFromUrl = decodeURIComponent(url).replace(/_/g, ' ')
|
||||||
result.year_to = parseInt(yearMatch[2])
|
const nameText = nameFromUrl + ' ' + (root.querySelector('h1') || { text: '' }).text
|
||||||
} else {
|
|
||||||
const singleYear = url.match(/(\d{4})_goda/)
|
const rangeMatch = nameText.match(/(\d{3,4})\s*[-–]\s*(\d{3,4})\s*(?:год|г\.|г\b)/i)
|
||||||
if (singleYear) {
|
if (rangeMatch) {
|
||||||
result.year_from = parseInt(singleYear[1])
|
const y1 = parseInt(rangeMatch[1]), y2 = parseInt(rangeMatch[2])
|
||||||
result.year_to = result.year_from
|
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 ───
|
// ─── 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) {
|
function guessMaterial(name) {
|
||||||
const n = name.toLowerCase()
|
const n = name.toLowerCase()
|
||||||
if (/золот|gold|\bgold\b/.test(n)) return 'Золото'
|
if (/золот|gold|\bgold\b/.test(n)) return 'Золото'
|
||||||
@@ -541,7 +808,7 @@ function buildSummary(coin, details, score, reasons) {
|
|||||||
function isCoin(name) {
|
function isCoin(name) {
|
||||||
const n = name.toLowerCase()
|
const n = name.toLowerCase()
|
||||||
// Exclude non-coins
|
// Exclude non-coins
|
||||||
if (/открытк|альбом|капсул|лист[ыа]\b|подставк|футляр|лупа|пинцет|рамк|планшет|холдер|книг/.test(n)) return false
|
if (/открытк|альбом|капсул|лист[ыа]\b|подставк|футляр|лупа|пинцет|рамк|планшет|холдер|книг|облигаци|почтовая марк|вексел|закладн[ао]|сертификат|письмо\b|пенсионн|задолженн|ипотек|залогов/.test(n)) return false
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -549,8 +816,32 @@ function isCoin(name) {
|
|||||||
// Based on research from 12+ professional numismatic sources:
|
// Based on research from 12+ professional numismatic sources:
|
||||||
// numizmatik.ru, Forbes.ru, kp.ru, thecoinsexplorer.com, trustedcoins.com,
|
// numizmatik.ru, Forbes.ru, kp.ru, thecoinsexplorer.com, trustedcoins.com,
|
||||||
// NGC, PCGS guides, coinweek.com, numisdon.com, zolotoy-zapas.ru, etc.
|
// NGC, PCGS guides, coinweek.com, numisdon.com, zolotoy-zapas.ru, etc.
|
||||||
// Silver price ~200 RUB/gram (ЦБ РФ, April 2026)
|
// Silver price per gram (fetched from ЦБ РФ daily, fallback 200)
|
||||||
const SILVER_PRICE_PER_GRAM = 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) {
|
function investmentScore(coin, details) {
|
||||||
let score = 0
|
let score = 0
|
||||||
@@ -946,7 +1237,7 @@ async function runScan() {
|
|||||||
const feedDetailBatch = db.transaction((items) => {
|
const feedDetailBatch = db.transaction((items) => {
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (!item.grade && !item.material && !item.year) continue
|
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)
|
const mat = item.material || guessMaterial(item.name)
|
||||||
detailFromFeed.run(item.id, item.grade, mat, item.weight, item.diameter, yearNum, yearNum, item.available)
|
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)
|
// Auto-parse product pages for coins missing details (grade/material)
|
||||||
// No need to parse individual product pages
|
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 = ?')
|
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)
|
.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 maxPrice = parseFloat(req.query.max_price || settings.max_price) || 3000
|
||||||
const minGrade = req.query.min_grade || settings.min_grade || 'VF'
|
const minGrade = req.query.min_grade || settings.min_grade || 'VF'
|
||||||
const materialFilter = (req.query.material || settings.preferred_material || '').toLowerCase()
|
const materialFilter = (req.query.material || settings.preferred_material || '').toLowerCase()
|
||||||
|
const countryFilter = (req.query.country || '').toLowerCase()
|
||||||
const onlyInStock = req.query.in_stock !== '0'
|
const onlyInStock = req.query.in_stock !== '0'
|
||||||
const feedFilter = req.query.feed || ''
|
const feedFilter = req.query.feed || ''
|
||||||
const limit = parseInt(req.query.limit) || 200
|
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)
|
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 => ({
|
coins = coins.map(c => ({
|
||||||
...c,
|
...c,
|
||||||
material: c.material || guessMaterial(c.name),
|
material: c.material || guessMaterial(c.name),
|
||||||
|
country: c.country || guessCountry(c.name),
|
||||||
|
special: detectSpecial(c.name),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Filter by material
|
// 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
|
// Score & sort with reasons
|
||||||
coins = coins.map(c => {
|
coins = coins.map(c => {
|
||||||
const { score, reasons } = investmentScore(c, {
|
const { score, reasons } = investmentScore(c, {
|
||||||
@@ -1175,6 +1511,21 @@ app.get('/api/scan/status', (req, res) => {
|
|||||||
|
|
||||||
// Stats
|
// Stats
|
||||||
// ─── Dashboard 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) => {
|
app.get('/api/stats', (req, res) => {
|
||||||
const total = db.prepare('SELECT COUNT(*) as cnt FROM coins WHERE available = 1').get().cnt
|
const total = db.prepare('SELECT COUNT(*) as cnt FROM coins WHERE available = 1').get().cnt
|
||||||
const withDetails = db.prepare('SELECT COUNT(*) as cnt FROM coin_details WHERE parsed_at IS NOT NULL').get().cnt
|
const 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()
|
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
|
// 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)
|
// Price drops (coins where current price < previous recorded price)
|
||||||
const priceDrops = db.prepare(`
|
const priceDrops = db.prepare(`
|
||||||
@@ -1224,6 +1575,18 @@ app.get('/api/dashboard', (req, res) => {
|
|||||||
ORDER BY last_seen DESC LIMIT 20
|
ORDER BY last_seen DESC LIMIT 20
|
||||||
`).all()
|
`).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
|
// Material breakdown
|
||||||
const materials = db.prepare(`
|
const materials = db.prepare(`
|
||||||
SELECT d.material, COUNT(*) as cnt, ROUND(AVG(c.price),0) as avg_price
|
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
|
GROUP BY d.material ORDER BY cnt DESC LIMIT 15
|
||||||
`).all()
|
`).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
|
// Grade breakdown
|
||||||
const grades = db.prepare(`
|
const grades = db.prepare(`
|
||||||
SELECT d.grade, COUNT(*) as cnt, ROUND(AVG(c.price),0) as avg_price
|
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
|
GROUP BY d.grade ORDER BY cnt DESC LIMIT 15
|
||||||
`).all()
|
`).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 ───
|
// ─── Price history for a coin ───
|
||||||
@@ -1251,20 +1639,115 @@ app.get('/api/coins/:id/history', (req, res) => {
|
|||||||
|
|
||||||
// ─── Cross-store comparison ───
|
// ─── Cross-store comparison ───
|
||||||
app.get('/api/compare', (req, res) => {
|
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
|
// Find coins with same name across different feeds
|
||||||
const dupes = db.prepare(`
|
const dupes = db.prepare(`
|
||||||
SELECT c1.name, c1.feed as feed1, c1.price as price1, c1.url as url1,
|
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.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
|
ROUND(ABS(c1.price - c2.price) / MAX(c1.price, c2.price) * 100) as diff_pct
|
||||||
FROM coins c1
|
FROM coins c1
|
||||||
JOIN coins c2 ON c1.name = c2.name AND c1.feed < c2.feed
|
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
|
ORDER BY diff_pct DESC
|
||||||
LIMIT 50
|
LIMIT ?
|
||||||
`).all()
|
`).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 })
|
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)
|
// Parse details for a specific coin (manual)
|
||||||
app.post('/api/coins/:id/parse', async (req, res) => {
|
app.post('/api/coins/:id/parse', async (req, res) => {
|
||||||
const coin = db.prepare('SELECT * FROM coins WHERE id = ?').get(req.params.id)
|
const coin = db.prepare('SELECT * FROM coins WHERE id = ?').get(req.params.id)
|
||||||
|
|||||||
Reference in New Issue
Block a user