Files
aspekter/coin-scout/coin-writer.js
s.zotov 718821fdd6 Initial commit: ASPEKTER — визуальный конструктор email-рассылок
- 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
2026-04-13 11:36:39 +05:00

312 lines
25 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 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 }