- z51-pug-builder: Svelte 5 SPA, визуальный редактор Pug-писем - email-gen: Node.js рендерер Pug→HTML через email-templates + Juice - email-gen-api: HTTP сервер рендеринга (порт 8787) - coin-scout: сервис подбора монет из фидов - Docker Compose для dev/prod - Nginx конфиг с SSL для app.aspekter.ru
312 lines
25 KiB
JavaScript
312 lines
25 KiB
JavaScript
// ─── Coin Writer v5: literary + factual balance ───
|
||
|
||
let gradeScore = () => -1
|
||
function setGradeScore(fn) { gradeScore = 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: [
|
||
'Пётр Великий перекроил Россию — от алфавита до монетной системы.',
|
||
'Царь, построивший новую столицу на болотах. Его монеты — среди самых желанных.',
|
||
'Реформатор, открывший России путь в Европу. Коллекционный спрос стабильно высокий.',
|
||
]},
|
||
{ re: /екатерин/i, from: 1762, to: 1796, texts: [
|
||
'При Екатерине II территория империи выросла на 500 тыс. кв. км. Монеты этого периода — классика.',
|
||
'Золотой век. 34 года правления и богатое нумизматическое наследие.',
|
||
]},
|
||
{ re: /александр\s*ii/i, from: 1855, to: 1881, texts: [
|
||
'Царь-Освободитель: отмена крепостного права, судебная реформа. Спрос на его монеты растёт.',
|
||
]},
|
||
{ re: /александр\s*iii/i, from: 1881, to: 1894, texts: [
|
||
'13 лет на троне. Единственный император, при котором Россия не вела ни одной войны. Монет — немного.',
|
||
]},
|
||
{ re: /павел/i, from: 1796, to: 1801, texts: [
|
||
'5 лет правления Павла I — и ограниченная чеканка. Каждый экземпляр нечаст.',
|
||
]},
|
||
{ re: /анна.*иоанн/i, from: 1730, to: 1740, texts: [
|
||
'Анна Иоанновна — эпоха дворцовых интриг. Монеты с особой притягательностью.',
|
||
]},
|
||
{ re: /елизавет/i, from: 1741, to: 1762, texts: [
|
||
'Дочь Петра Великого. При Елизавете расцвело монетное дело — изысканная чеканка, редкие тиражи.',
|
||
]},
|
||
{ re: /финлянд/i, texts: [
|
||
'Русская Финляндия — обособленная серия. Монеты чеканились для Великого княжества и обращались только на его территории.',
|
||
'Монеты Великого княжества Финляндского — компактная серия с преданным кругом ценителей.',
|
||
]},
|
||
{ re: /римск|roman|денарий|антониниан/i, texts: [
|
||
'Рим: легионы, акведуки, Колизей — и монеты, пережившие саму империю. Глобальный рынок растёт на 8-12% в год.',
|
||
'Монета цивилизации, построившей дороги, по которым ходят до сих пор.',
|
||
]},
|
||
{ re: /греч|greek|драхм|обол|тетра/i, texts: [
|
||
'Древняя Греция: Сократ, Олимпийские игры и первые в истории монеты с портретами. Рынок растёт до 15% ежегодно.',
|
||
]},
|
||
{ re: /боспор|пантикапей/i, texts: [
|
||
'Боспорское царство — античный Крым. Монеты Пантикапея — растущая ниша для ценителей причерноморской истории.',
|
||
]},
|
||
{ re: /визант|byzant/i, texts: [
|
||
'Византия просуществовала тысячу лет — дольше любой европейской империи. Доступная и перспективная античность.',
|
||
]},
|
||
{ re: /осман|ottoman/i, texts: [
|
||
'Османская империя раскинулась от Дуная до Аравии. Пока недооценённый, но быстро растущий сегмент.',
|
||
]},
|
||
{ re: /сефевид|safavid/i, texts: [
|
||
'Сефевидская Персия — империя шахов, объединившая Иран. Монеты этой династии встречаются всё реже.',
|
||
]},
|
||
{ re: /смутн|владислав|лжедмитри/i, texts: [
|
||
'Смутное время — самые драматичные годы русской истории. Эти монеты — нумизматическая элита.',
|
||
'Между царствами, между эпохами. Монеты Смутного времени доступны единицам.',
|
||
]},
|
||
{ re: /медный бунт/i, texts: [
|
||
'Медный бунт 1662 года: народное восстание против обесценивания денег. Монеты-свидетели этих событий крайне редки.',
|
||
]},
|
||
{ re: /сибирск|сузун/i, texts: [
|
||
'Сибирские монеты чеканились из меди Колывано-Воскресенских заводов с примесью серебра и золота. Ходили только за Уралом.',
|
||
]},
|
||
{ re: /георгий победоносец/i, texts: [
|
||
'«Георгий Победоносец» — самая ликвидная монета из драгметаллов в России. Принимается в любом банке.',
|
||
]},
|
||
{ re: /1921|1922|1923|1924|1925|1926|1927|1928|1929|1930|1931/i, materialRe: /серебро|silver/, texts: [
|
||
'Раннее СССР серебро: последний период серебра в обращении. Большинство изъято и переплавлено в 1930-х — уцелевшие экземпляры особенно ценны.',
|
||
]},
|
||
]
|
||
|
||
function generateCoinEmail(coin, details, score) {
|
||
const name = coin.name || ''
|
||
const mat = (details.material || '').toLowerCase()
|
||
const year = details.year_from || 0
|
||
const age = year ? 2026 - year : 0
|
||
const price = coin.price || 0
|
||
const grade = details.grade || ''
|
||
const gs = gradeScore(grade)
|
||
const weightG = parseFloat(details.weight) || 0
|
||
const isSilver = /серебро|silver/.test(mat)
|
||
const isGold = /золото|gold/.test(mat)
|
||
const isPrecious = isSilver || isGold
|
||
const h = hash(coin.id || name)
|
||
const totalAge = year < 0 ? Math.abs(year) + 2026 : age
|
||
|
||
const p1 = []
|
||
const p2 = []
|
||
const p3 = []
|
||
|
||
// ══════════ P1: HOOK — литературно + факт ══════════
|
||
|
||
if (totalAge > 2000) {
|
||
p1.push(pick([
|
||
`${q(name)}. Этой монете более ${totalAge} лет — она старше большинства государств на карте мира. Когда её чеканили, ещё существовала Римская империя.`,
|
||
`Представьте: ${totalAge} лет назад кто-то расплатился этой монетой на рыночной площади древнего города. ${q(name)} — подлинный артефакт, переживший тысячелетия.`,
|
||
`${q(name)}. ${totalAge} лет истории в одном небольшом кружке металла. Монета, к которой прикасались люди из совершенно другого мира.`,
|
||
], h))
|
||
} else if (totalAge > 400) {
|
||
p1.push(pick([
|
||
`${q(name)} — ${totalAge} лет истории. Монета, которая пережила империи, войны и революции, и дошла до наших дней.`,
|
||
`${totalAge} лет назад мастер ударил штемпелем по заготовке — так появилась ${q(name)}. С тех пор мир изменился до неузнаваемости.`,
|
||
`${q(name)}. ${totalAge} лет — и монета по-прежнему существует. Каждая её потёртость — след чьей-то жизни, давно забытой историей.`,
|
||
], h))
|
||
} else if (age > 200) {
|
||
p1.push(pick([
|
||
`${q(name)} — больше ${Math.floor(age / 100)} столетий истории. Монета из мира без электричества и фотографии, дошедшая до наших дней${gs >= gradeScore('VF') ? ' в достойном состоянии' : ''}.`,
|
||
`${q(name)}. ${age} лет назад она была частью чьей-то повседневности. Сегодня — коллекционная ценность.`,
|
||
`Больше ${Math.floor(age / 100)} веков назад эта монета зазвенела впервые. ${q(name)} — осколок эпохи, которую мы знаем лишь по книгам.`,
|
||
], h))
|
||
} else if (age > 100) {
|
||
p1.push(pick([
|
||
`${q(name)}. ${year} год — мир на пороге грандиозных перемен.${isPrecious ? ` ${isGold ? 'Золотая' : 'Серебряная'} монета, ценная и как металл, и как предмет коллекционирования.` : ''}`,
|
||
`${q(name)} — больше века истории. Монета, отчеканенная когда мир был совсем другим.${isPrecious ? ` ${isGold ? 'Золото' : 'Серебро'}.` : ''}`,
|
||
], h))
|
||
} else {
|
||
p1.push(`${q(name)}.${year ? ` ${year} год.` : ''}${isPrecious ? ` ${isGold ? 'Золотая' : 'Серебряная'} монета.` : ''}`)
|
||
}
|
||
|
||
// Period context
|
||
for (const period of PERIODS) {
|
||
if (!period.re.test(name)) continue
|
||
if (period.from && year && (year < period.from || year > period.to)) continue
|
||
if (period.materialRe && !period.materialRe.test(mat)) continue
|
||
p1.push(pick(period.texts, h + 7))
|
||
break
|
||
}
|
||
|
||
// ══════════ CONTENT BLOCKS — independent, shuffleable ══════════
|
||
const blocks = []
|
||
|
||
// Block: Grade
|
||
if (gs >= gradeScore('Proof')) {
|
||
blocks.push(pick([
|
||
'Качество Proof — зеркальная поверхность, матовый рельеф. Безупречна в каждой детали.',
|
||
'Proof-чекан: полированные штемпели, идеальная заготовка — совершенство линий.',
|
||
'Proof — высшая категория качества. Зеркальное поле, матовый рельеф.',
|
||
], h + 10))
|
||
} else if (gs >= gradeScore('UNC')) {
|
||
blocks.push(pick([
|
||
`Сохранность UNC — не была в обращении. Полный штемпельный блеск.${age > 50 ? ` Для ${age}-летней монеты — редкость.` : ''}`,
|
||
`UNC — как в день чеканки.${age > 100 ? ` ${age} лет — и ни следа износа.` : ' Ни единого следа времени.'}`,
|
||
`Не была в обращении (UNC). Оригинальный блеск, безупречный рельеф.${age > 50 ? ` Через ${age} лет — впечатляет.` : ''}`,
|
||
], h + 10))
|
||
} else if (gs >= gradeScore('AU')) {
|
||
blocks.push(pick([
|
||
`AU — почти идеальное состояние. Едва заметные следы на выступающих точках.${age > 100 ? ` Для ${age}-летней монеты — отличный результат.` : ''}`,
|
||
`AU — на грани между обращением и совершенством.${age > 100 ? ` Через ${age} лет — это удача.` : ''}`,
|
||
`Almost Uncirculated. Минимальный износ.${age > 100 ? ` ${age} лет — и такое состояние.` : ' Блеск сохранён.'}`,
|
||
], h + 10))
|
||
} else if (gs >= gradeScore('XF')) {
|
||
blocks.push(pick([
|
||
`XF — чёткий рельеф, все детали выразительны.${age > 200 ? ` Для ${age}-летней монеты — впечатляюще. Большинство ровесников дошли в худшем виде.` : ''}`,
|
||
`Extremely Fine.${age > 200 ? ` ${age} лет — а рисунок практически полный.` : ' Лёгкий износ, все элементы на месте.'}`,
|
||
`XF — высокая сохранность.${age > 200 ? ` Для монеты ${age}-летней давности — нечастый грейд.` : ''}`,
|
||
], h + 10))
|
||
} else if (gs >= gradeScore('VF')) {
|
||
blocks.push(`Сохранность ${grade}.${age > 200 ? ` Для ${age}-летней монеты — достойно.` : ''}`)
|
||
}
|
||
|
||
// Block: Material + melt
|
||
if (isSilver && weightG > 0 && price > 0) {
|
||
const melt = Math.round(weightG * 200)
|
||
const ratio = Math.round(melt / price * 100)
|
||
if (ratio > 100) {
|
||
blocks.push(pick([
|
||
`Серебро (${weightG}г) стоит ~${melt}₽ по курсу ЦБ — а монета продаётся дешевле. Такие аномалии встречаются нечасто.`,
|
||
`${weightG}г серебра ≈ ${melt}₽. Стоимость металла превышает стоимость монеты.`,
|
||
`Серебра внутри на ${melt}₽ — дороже самой монеты. Арифметика на стороне покупателя.`,
|
||
], h + 12))
|
||
} else if (ratio > 60) {
|
||
blocks.push(pick([
|
||
`${weightG}г серебра (~${melt}₽) покрывают ${ratio}% стоимости. Надёжный фундамент.`,
|
||
`Серебро (${weightG}г, ~${melt}₽) — ${ratio}% от стоимости. Остальное — коллекционная премия.`,
|
||
], h + 12))
|
||
} else {
|
||
blocks.push(`Серебро, ${weightG}г. Драгоценный металл обеспечивает базовую поддержку стоимости.`)
|
||
}
|
||
} else if (isSilver) {
|
||
blocks.push(pick([
|
||
'Серебряная монета — ценность, проверенная веками.',
|
||
'Серебро — классика нумизматики.',
|
||
'Серебро обеспечивает ликвидность — драгоценный металл всегда найдёт покупателя.',
|
||
], h + 12))
|
||
} else if (isGold) {
|
||
blocks.push(pick([
|
||
'Золото — металл, который ценили во все времена и во всех цивилизациях.',
|
||
'Золотая монета — и артефакт, и ценность, проверенная тысячелетиями.',
|
||
'Золото не подвластно времени. Металл королей и императоров.',
|
||
], h + 12))
|
||
}
|
||
|
||
// Block: Scarcity
|
||
if (age > 200) {
|
||
blocks.push(pick([
|
||
`Предложение монет ${age > 300 ? 'этой эпохи' : 'этого периода'} сокращается — утраты и оседание в коллекциях делают каждый экземпляр ценнее.`,
|
||
`Число сохранившихся экземпляров может только уменьшаться. Через десять лет найти аналог будет сложнее.`,
|
||
`Каждый экземпляр, уходящий в коллекцию, сужает предложение. Процесс необратим.`,
|
||
], h + 15))
|
||
} else if (age > 100) {
|
||
blocks.push(pick([
|
||
'Монет этого периода на рынке меньше с каждым десятилетием.',
|
||
'Доступных экземпляров всё меньше — их больше не чеканят.',
|
||
], h + 15))
|
||
}
|
||
|
||
// Block: Collector appeal
|
||
if (isPrecious && gs >= gradeScore('VF')) {
|
||
blocks.push(pick([
|
||
'Драгоценный металл и хорошая сохранность — сочетание, которое ценилось во все времена.',
|
||
'Металл и состояние — два главных фактора в нумизматике. Оба на месте.',
|
||
'Сохранность и драгоценный металл — то, за чем охотятся коллекционеры.',
|
||
'Коллекционеры ценят прежде всего металл и состояние. Здесь — и то, и другое.',
|
||
], h + 16))
|
||
}
|
||
|
||
// Block: Sensory
|
||
if (h % 3 === 0) {
|
||
if (age > 300 && !isGold) blocks.push(pick(['Благородная патина подтверждает подлинность и придаёт монете неповторимый характер.', 'Следы ручной чеканки — каждая такая монета уникальна, двух одинаковых не существует.'], h + 18))
|
||
else if (isSilver && weightG > 3) blocks.push(pick(['Приятная тяжесть настоящего серебра в ладони — ощущение, знакомое коллекционерам по всему миру.', 'Характерный холод серебра в руке. Подлинность чувствуется на ощупь.'], h + 18))
|
||
else if (isGold) blocks.push(pick(['Тёплый блеск золота, который не тускнеет веками.', 'Особая тяжесть золота в руке — ощущение, которое ни с чем не спутать.'], h + 18))
|
||
}
|
||
|
||
// Block: Collecting context — почему коллекционеры собирают именно такие
|
||
if (h % 3 === 1) {
|
||
if (age > 300) {
|
||
blocks.push(pick([
|
||
'Коллекционирование античных монет — одно из старейших хобби в мире. Им увлекались Петрарка, Медичи и российские императоры.',
|
||
'Античная нумизматика — область, где каждая монета является историческим документом. Надписи, портреты, символы рассказывают о правителях и событиях.',
|
||
'Каждая античная монета чеканилась вручную — поэтому двух абсолютно одинаковых не существует. Каждый экземпляр уникален.',
|
||
], h + 19))
|
||
} else if (year >= 1700 && year <= 1917) {
|
||
blocks.push(pick([
|
||
'Монеты Российской империи — одно из самых популярных направлений в отечественной нумизматике. Спрос стабилен и предсказуем.',
|
||
'Имперские монеты привлекают коллекционеров сочетанием доступности и исторической глубины. Многие серии можно собирать по годам, монетным дворам и разновидностям.',
|
||
'Русская нумизматика XVIII-XIX веков — направление с устоявшимся рынком, каталогами и аукционной историей. Риск приобрести подделку минимален при покупке у проверенных дилеров.',
|
||
], h + 19))
|
||
} else if (/серебро|silver/.test(mat)) {
|
||
blocks.push(pick([
|
||
'Серебряные монеты — один из самых ликвидных сегментов нумизматики. Их легко оценить, легко продать, легко хранить.',
|
||
'Коллекционирование серебряных монет совмещает эстетическое удовольствие и рациональный подход к сохранению ценности.',
|
||
], h + 19))
|
||
}
|
||
}
|
||
|
||
// Block: Technical facts — интересные факты о чеканке
|
||
if (h % 3 === 2) {
|
||
if (isSilver && weightG > 0) {
|
||
blocks.push(pick([
|
||
`Вес монеты — ${weightG}г. В эпоху чеканки вес строго контролировался: отклонение означало фальсификацию и каралось по закону.`,
|
||
`${weightG}г серебра. Монетная стопа — соотношение веса монеты к номиналу — тщательно регулировалась государством.`,
|
||
`Серебро ${weightG}г. Каждая монета проходила весовой контроль — это было гарантией доверия к денежной системе.`,
|
||
], h + 20))
|
||
} else if (isGold && weightG > 0) {
|
||
blocks.push(pick([
|
||
`Вес: ${weightG}г золота. Золотые монеты чеканились с особой точностью — даже минимальное отклонение веса было недопустимо.`,
|
||
`${weightG}г золота. Золотая монета — это не просто деньги, это государственная гарантия пробы и веса.`,
|
||
], h + 20))
|
||
} else if (age > 200) {
|
||
blocks.push(pick([
|
||
'В эпоху чеканки этой монеты каждый экземпляр проходил ручной контроль качества. Брак уничтожался и переплавлялся.',
|
||
'Монетное дело в ту эпоху было одной из важнейших государственных функций — от качества монеты зависело доверие к экономике.',
|
||
], h + 20))
|
||
}
|
||
}
|
||
|
||
// Block: Gift / collection starter
|
||
if (h % 5 === 0 && age > 50) {
|
||
blocks.push(pick([
|
||
'Такая монета может стать центральным экземпляром коллекции или запоминающимся подарком для ценителя истории.',
|
||
'Монета с историей — подарок, который не теряет значения с годами. Напротив — только набирает.',
|
||
'Отличный экземпляр для начала коллекции или пополнения существующей.',
|
||
], h + 21))
|
||
}
|
||
|
||
// Block: Provenance / geography
|
||
if (h % 5 === 1) {
|
||
if (/москв|moscow|ммд/i.test(name)) blocks.push('Отчеканена на Московском монетном дворе — одном из старейших в России.')
|
||
else if (/петербург|спб|спмд|ленинград/i.test(name)) blocks.push('Чеканка Санкт-Петербургского монетного двора — предприятия, основанного Петром I в 1724 году.')
|
||
else if (/рига|riga/i.test(name)) blocks.push('Рижский монетный двор чеканил монету на протяжении нескольких столетий для разных государств.')
|
||
else if (/сузун/i.test(name)) blocks.push('Сузунский монетный двор на Алтае работал с 1763 по 1847 год — единственное подобное предприятие за Уралом.')
|
||
}
|
||
|
||
// ══════════ SHUFFLE (grade stays first, rest randomized) ══════════
|
||
const first = blocks.shift() || ''
|
||
const rest = [...blocks]
|
||
for (let i = rest.length - 1; i > 0; i--) {
|
||
const j = (h + i * 13) % (i + 1)
|
||
;[rest[i], rest[j]] = [rest[j], rest[i]]
|
||
}
|
||
const body = [first, ...rest].filter(Boolean)
|
||
const mid = Math.ceil(body.length / 2)
|
||
|
||
return [p1.join(' '), body.slice(0, mid).join(' '), body.slice(mid).join(' ')].filter(p => p && p.trim()).join('\n\n')
|
||
}
|
||
|
||
module.exports = { generateCoinEmail, setGradeScore }
|