Files
aspekter/coin-scout/coin-writer.js
s.zotov d34f04e922 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
2026-04-13 12:39:25 +05:00

241 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ─── Coin Writer v8: literary + concise ───
let gradeScore = () => -1
function setGradeScore(fn) { gradeScore = fn }
let getSilverPrice = () => 200
function setSilverPrice(fn) { getSilverPrice = fn }
function hash(str) {
let h = 0
for (let i = 0; i < str.length; i++) h = ((h << 5) - h + str.charCodeAt(i)) | 0
return Math.abs(h)
}
function pick(arr, seed) { return arr[seed % arr.length] }
function q(name) { return `«${name}»` }
const PERIODS = [
{ re: /николай\s*ii|николая\s*ii/i, texts: [
'Закат империи, эпоха золотого стандарта и Транссибирской магистрали. Монеты Николая II неизменно лидируют на аукционах русской нумизматики — от Москвы до Нью-Йорка.',
'Последний император. Его монеты — витрина русской нумизматики: безупречная чеканка, прозрачная аукционная история, спрос, не ослабевающий десятилетиями.',
'Николай II — самый коллекционируемый период Империи. Каталог Биткина знает каждый штемпель, аукционы отслеживают каждую продажу.',
]},
{ re: /пётр|петр\s*i|петра\s*i/i, from: 1682, to: 1725, texts: [
'Пётр Великий перекроил Россию — от алфавита до монетной системы. Реформа 1700 года дала стране копейку, гривенник, полтинник и рубль. Его монеты — фундамент любой серьёзной коллекции.',
'Царь, построивший новую столицу на болотах и новую монетную систему с нуля. Среди самых желанных монет отечественной нумизматики.',
]},
{ re: /екатерин/i, from: 1762, to: 1796, texts: [
'Золотой век Российской империи. При Екатерине II территория выросла на 500 тыс. кв. км, а монетное искусство достигло расцвета — изящные портретные типы, совершенная чеканка.',
]},
{ re: /александр\s*ii/i, from: 1855, to: 1881, texts: [
'Царь-Освободитель: отмена крепостного права, земская реформа, продажа Аляски. Монеты его эпохи переживают ренессанс коллекционного спроса.',
]},
{ re: /александр\s*iii/i, from: 1881, to: 1894, texts: [
'Тринадцать мирных лет на троне — единственный император, при котором Россия не вела ни одной войны. Короткое правление — ограниченная чеканка, каждый экземпляр на счету.',
]},
{ re: /павел/i, from: 1796, to: 1801, texts: [
'Всего пять лет правления Павла I. Монеты с его характерным вензелем нечасты и неизменно востребованы.',
]},
{ re: /анна.*иоанн/i, from: 1730, to: 1740, texts: [
'Анна Иоанновна — эпоха дворцовых переворотов и бироновщины. Монеты с её портретом притягивают ценителей XVIII века.',
]},
{ re: /елизавет/i, from: 1741, to: 1762, texts: [
'Дочь Петра Великого. При Елизавете расцвело монетное искусство — утончённые портреты, безупречная чеканка, редкие тиражи.',
]},
{ re: /финлянд/i, texts: [
'Монеты Великого княжества Финляндского — обособленная серия, чеканившаяся для территории, где ходили свои номиналы: пенни и марки с портретами российских императоров. Компактная серия с преданным кругом ценителей.',
]},
{ re: /римск|roman|денарий|антониниан/i, texts: [
'Рим: легионы, акведуки, Колизей — и монеты, пережившие империю на два тысячелетия. Каждый денарий — исторический документ, побывавший в руках людей, о которых мы читаем в учебниках.',
'Монета цивилизации, построившей дороги, по которым ходят до сих пор. Мировой рынок античной нумизматики растёт на 812% ежегодно.',
]},
{ re: /греч|greek|драхм|обол|тетра/i, texts: [
'Древняя Греция — родина монетного дела. Совы Афин, тетрадрахмы Александра Македонского: спрос на эти монеты не ослабевает тысячелетиями.',
]},
{ re: /боспор|пантикапей/i, texts: [
'Боспорское царство — античный Крым, перекрёсток эллинского и скифского миров. Монеты Пантикапея с головой сатира узнаются с первого взгляда.',
]},
{ re: /визант|byzant/i, texts: [
'Византия простояла тысячу лет — дольше любой европейской империи. Её солиды были мировой резервной валютой раннего Средневековья.',
]},
{ re: /осман|ottoman/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: [
'Ранний СССР — последний период, когда драгоценный металл ещё ходил в обращении. В 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: [
'Талер — прародитель доллара, крупная серебряная монета, ходившая по всей Европе. Ликвиден в любой точке мира.',
]},
]
function generateCoinEmail(coin, details, score) {
const name = coin.name || ''
const mat = (details.material || '').toLowerCase()
const year = details.year_from || 0
const age = year > 0 ? 2026 - year : 0
const price = coin.price || 0
const grade = details.grade || ''
const gs = gradeScore(grade)
const weightG = parseFloat(details.weight) || 0
const diameterMm = parseFloat(details.diameter) || 0
const isSilver = /серебро|silver/.test(mat)
const isGold = /золото|gold/.test(mat)
const isPrecious = isSilver || isGold
const h = hash(coin.id || name)
const metalWord = isGold ? 'золото' : isSilver ? 'серебро' : ''
const metalAdj = isGold ? 'золотая' : isSilver ? 'серебряная' : ''
const parts = []
// ══════════ АБЗАЦ 1: КРЮЧОК — образ + исторический контекст ══════════
const hook = []
if (age > 400) {
hook.push(pick([
`${q(name)}. Этой монете ${age} лет — она старше большинства государств на современной карте. Когда её чеканили, мастер бил штемпелем по раскалённой заготовке вручную, и каждый экземпляр получался неповторимым.`,
`${age} лет назад кто-то расплатился этой монетой. С тех пор рухнули империи, сменились языки и границы — а ${q(name)} дошла до наших дней.`,
`${q(name)}${age} лет в одном кружке металла. Каждая потёртость на ней — след чьей-то жизни, давно забытой историей.`,
], h))
} else if (age > 200) {
hook.push(pick([
`${q(name)}. ${year} год — мир без электричества и фотографии. Эта монета помнит то, чего уже не помнит никто из живущих.`,
`Больше двух столетий назад ${q(name)} впервые зазвенела на прилавке. Сегодня она — осколок эпохи, которую мы знаем лишь по книгам.`,
`${year} год. ${q(name)} — монета, пережившая больше двух столетий${gs >= gradeScore('XF') ? ' и дошедшая до нас в прекрасном состоянии' : ''}.`,
], h))
} else if (age > 100) {
hook.push(pick([
`${q(name)}. ${year} год — мир на пороге перемен, которые изменят всё.`,
`${year} год. ${q(name)} — больше века истории. Монета из совершенно другого мира.`,
], h))
} else {
hook.push(`${q(name)}.${year ? ` ${year} год.` : ''}`)
}
// Исторический контекст
for (const period of PERIODS) {
if (!period.re.test(name)) continue
if (period.from && year && (year < period.from || year > period.to)) continue
if (period.materialRe && !period.materialRe.test(mat)) continue
hook.push(pick(period.texts, h + 7))
break
}
parts.push(hook.join(' '))
// ══════════ АБЗАЦ 2: ТЕЛО — грейд + материал + одна деталь ══════════
const body = []
// Грейд — образно, но точно
if (gs >= gradeScore('Proof')) {
body.push(pick([
'Качество Proof — зеркальное поле, матовый рельеф. Совершенство, созданное для ценителей, а не для кошелька.',
'Proof-чекан: полированные штемпели, безупречная заготовка. Каждая линия рисунка передана с фотографической точностью.',
], h + 10))
} else if (gs >= gradeScore('UNC')) {
body.push(pick([
`UNC — монета не была в обращении. Полный штемпельный блеск, нетронутый рельеф.${age > 100 ? ` Через ${age} лет — это почти чудо: значит, кто-то берёг её с самого начала.` : ''}`,
`Сохранность UNC — как в день чеканки.${age > 50 ? ` ${age} лет, и ни единого следа износа. Такое не часто встретишь.` : ' Оригинальный штемпельный блеск.'}`,
], h + 10))
} else if (gs >= gradeScore('AU')) {
body.push(pick([
`AU — почти идеальное состояние. Едва заметные следы на самых выступающих точках рельефа, основной блеск сохранён.${age > 100 ? ` Для ${age}-летней монеты — отличный результат.` : ''}`,
`Almost Uncirculated — на грани между обращением и совершенством.${age > 100 ? ` ${age} лет — и такое состояние. Владельцы явно берегли эту монету.` : ' Минимальный износ, блеск на месте.'}`,
], h + 10))
} else if (gs >= gradeScore('XF')) {
body.push(pick([
`XF — чёткий рельеф, все детали выразительны, все надписи читаются без труда.${age > 200 ? ` Для ${age}-летней монеты — впечатляюще. Большинство её ровесников дошли до нас в худшем виде.` : ''}`,
`Extremely Fine — высокая коллекционная сохранность.${age > 200 ? ` Монете ${age} лет, а рисунок практически полный. Нечастый результат.` : ' Лёгкий износ на выступающих частях, все элементы на месте.'}`,
], h + 10))
} else if (gs >= gradeScore('VF')) {
body.push(`Сохранность ${grade} — все основные детали различимы, честный коллекционный экземпляр.${age > 200 ? ` Для ${age}-летней монеты — достойно.` : ''}`)
}
// Материал — конкретика, без лишних повторов слова «серебро»/«золото»
if (isSilver && weightG > 0 && price > 0) {
const melt = Math.round(weightG * getSilverPrice())
const ratio = Math.round(melt / price * 100)
if (ratio > 100) {
body.push(pick([
`Внутри — ${weightG}г драгоценного металла на ${melt}₽ по курсу ЦБ. Монета стоит дешевле содержимого. Нумизматическую ценность вы получаете в подарок.`,
`${weightG}г при текущем курсе ≈ ${melt}₽. Стоимость металла превышает цену монеты — нечастая ситуация.`,
], h + 12))
} else if (ratio > 60) {
body.push(`${weightG}г драгоценного металла (~${melt}₽) покрывают ${ratio}% стоимости — надёжный фундамент. Монета вряд ли подешевеет ниже стоимости содержимого.`)
} else if (weightG > 2) {
body.push(`${weightG}г${diameterMm ? `, Ø${diameterMm}мм` : ''}. Драгоценный металл обеспечивает ликвидность — такую монету всегда проще продать.`)
}
} else if (isGold && weightG > 0) {
body.push(`${weightG}г${diameterMm ? `, Ø${diameterMm}мм` : ''}. Двойная природа: нумизматический экземпляр и драгоценный металл. Одно усиливает другое.`)
} else if (isGold) {
body.push('Драгоценный металл, не подвластный времени. Ценность, которая не нуждается в объяснениях.')
} else if (isSilver) {
body.push('Классика нумизматического собирательства. Благородная патина, приятная тактильность, стабильный спрос.')
}
// Одна деталь — тактильность, монетный двор или факт (не больше одной!)
const detailPool = []
if (age > 300 && !isGold && h % 3 === 0) detailPool.push(pick([
'Благородная патина подчёркивает рельеф — такую не подделать, она формируется десятилетиями.',
'Следы ручной чеканки делают каждый экземпляр уникальным — двух одинаковых не существует.',
], h + 18))
if (isSilver && weightG > 3 && h % 3 === 1) detailPool.push(pick([
`Приятная тяжесть ${weightG} граммов в ладони — ощущение, которое не передать фотографией.`,
`Характерный холод металла в руке${diameterMm ? `, кружок ${diameterMm}мм` : ''} — подлинность чувствуется на ощупь.`,
], h + 18))
if (isGold && h % 3 === 1) detailPool.push('Тёплый блеск, который не тускнеет веками. Особенная плотность ощущается сразу — не спутать ни с чем.')
if (/спб|петербург/i.test(name) && h % 3 === 2) detailPool.push('Чеканка Петербургского монетного двора — предприятия, основанного Петром I в 1724 году в Петропавловской крепости.')
if (/сузун/i.test(name) && h % 3 === 2) detailPool.push('Сузунский монетный двор (17631847) — единственное подобное предприятие за Уралом.')
if (isSilver && weightG > 0 && h % 3 === 2) detailPool.push(`Вес${weightG}г. В эпоху чеканки каждая монета проходила весовой контроль: отклонение от стандарта означало фальсификацию.`)
if (detailPool.length) body.push(detailPool[0])
if (body.length) parts.push(body.join(' '))
// ══════════ АБЗАЦ 3: ЗАКРЫТИЕ — почему сейчас ══════════
const closes = []
if (age > 200) {
closes.push(pick([
'Таких монет на рынке с каждым годом меньше — экземпляры уходят в постоянные коллекции и музейные фонды. Процесс необратим: их больше не чеканят.',
`Число доступных экземпляров ${age > 300 ? 'этой эпохи' : 'этого периода'} может только уменьшаться. То, что доступно сегодня, завтра может оказаться в чьей-то постоянной коллекции.`,
'Предложение сокращается с каждым годом — утраты, оседание в коллекциях, музейные фонды. Обратного хода нет.',
], h + 15))
} else if (age > 100) {
closes.push(pick([
'Столетних монет на рынке не прибавляется. С каждым десятилетием найти достойный экземпляр всё сложнее.',
'Их не чеканят уже больше века. Каждый экземпляр, ушедший в коллекцию, сужает предложение навсегда.',
], h + 15))
}
if (isPrecious && gs >= gradeScore('AU') && !closes.length) {
closes.push('Драгоценный металл в сочетании с высоким грейдом — формула, которая работает на протяжении всей истории нумизматики.')
}
if (closes.length) parts.push(closes[0])
return parts.filter(p => p && p.trim()).join('\n\n')
}
module.exports = { generateCoinEmail, setGradeScore, setSilverPrice }