From d34f04e922d421fe8011642c6974abd3de3b00a1 Mon Sep 17 00:00:00 2001 From: "s.zotov" Date: Mon, 13 Apr 2026 12:39:25 +0500 Subject: [PATCH] =?UTF-8?q?Rename=20EMAILBRO=20=E2=86=92=20ASPEKTER,=20upd?= =?UTF-8?q?ate=20Coin=20Scout,=20security=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- coin-scout/coin-writer.js | 325 +++++++++------------ coin-scout/package.json | 3 +- coin-scout/public/about.html | 230 +++++++++++++++ coin-scout/public/index.html | 258 ++++++++++++++++- coin-scout/server.js | 535 +++++++++++++++++++++++++++++++++-- 5 files changed, 1115 insertions(+), 236 deletions(-) create mode 100644 coin-scout/public/about.html diff --git a/coin-scout/coin-writer.js b/coin-scout/coin-writer.js index c04731e..4bfc5f5 100644 --- a/coin-scout/coin-writer.js +++ b/coin-scout/coin-writer.js @@ -1,8 +1,11 @@ -// ─── Coin Writer v5: literary + factual balance ─── +// ─── Coin Writer v8: literary + concise ─── let gradeScore = () => -1 function setGradeScore(fn) { gradeScore = fn } +let getSilverPrice = () => 200 +function setSilverPrice(fn) { getSilverPrice = fn } + function hash(str) { let h = 0 for (let i = 0; i < str.length; i++) h = ((h << 5) - h + str.charCodeAt(i)) | 0 @@ -13,73 +16,77 @@ function q(name) { return `«${name}»` } const PERIODS = [ { re: /николай\s*ii|николая\s*ii/i, texts: [ - 'Последний российский император — его монеты неизменно возглавляют аукционные продажи.', - 'Эпоха Николая II: Транссибирская магистраль, первые автомобили — и монеты, ставшие символом ушедшей империи.', - 'Монеты Николая II коллекционируются по всему миру. Спрос не ослабевает десятилетиями.', - 'Закат Российской империи. Его монеты — одни из самых собираемых в русской нумизматике.', + 'Закат империи, эпоха золотого стандарта и Транссибирской магистрали. Монеты Николая II неизменно лидируют на аукционах русской нумизматики — от Москвы до Нью-Йорка.', + 'Последний император. Его монеты — витрина русской нумизматики: безупречная чеканка, прозрачная аукционная история, спрос, не ослабевающий десятилетиями.', + 'Николай II — самый коллекционируемый период Империи. Каталог Биткина знает каждый штемпель, аукционы отслеживают каждую продажу.', ]}, { re: /пётр|петр\s*i|петра\s*i/i, from: 1682, to: 1725, texts: [ - 'Пётр Великий перекроил Россию — от алфавита до монетной системы.', - 'Царь, построивший новую столицу на болотах. Его монеты — среди самых желанных.', - 'Реформатор, открывший России путь в Европу. Коллекционный спрос стабильно высокий.', + 'Пётр Великий перекроил Россию — от алфавита до монетной системы. Реформа 1700 года дала стране копейку, гривенник, полтинник и рубль. Его монеты — фундамент любой серьёзной коллекции.', + 'Царь, построивший новую столицу на болотах и новую монетную систему с нуля. Среди самых желанных монет отечественной нумизматики.', ]}, { re: /екатерин/i, from: 1762, to: 1796, texts: [ - 'При Екатерине II территория империи выросла на 500 тыс. кв. км. Монеты этого периода — классика.', - 'Золотой век. 34 года правления и богатое нумизматическое наследие.', + 'Золотой век Российской империи. При Екатерине II территория выросла на 500 тыс. кв. км, а монетное искусство достигло расцвета — изящные портретные типы, совершенная чеканка.', ]}, { re: /александр\s*ii/i, from: 1855, to: 1881, texts: [ - 'Царь-Освободитель: отмена крепостного права, судебная реформа. Спрос на его монеты растёт.', + 'Царь-Освободитель: отмена крепостного права, земская реформа, продажа Аляски. Монеты его эпохи переживают ренессанс коллекционного спроса.', ]}, { re: /александр\s*iii/i, from: 1881, to: 1894, texts: [ - '13 лет на троне. Единственный император, при котором Россия не вела ни одной войны. Монет — немного.', + 'Тринадцать мирных лет на троне — единственный император, при котором Россия не вела ни одной войны. Короткое правление — ограниченная чеканка, каждый экземпляр на счету.', ]}, { re: /павел/i, from: 1796, to: 1801, texts: [ - '5 лет правления Павла I — и ограниченная чеканка. Каждый экземпляр нечаст.', + 'Всего пять лет правления Павла I. Монеты с его характерным вензелем нечасты и неизменно востребованы.', ]}, { re: /анна.*иоанн/i, from: 1730, to: 1740, texts: [ - 'Анна Иоанновна — эпоха дворцовых интриг. Монеты с особой притягательностью.', + 'Анна Иоанновна — эпоха дворцовых переворотов и бироновщины. Монеты с её портретом притягивают ценителей XVIII века.', ]}, { re: /елизавет/i, from: 1741, to: 1762, texts: [ - 'Дочь Петра Великого. При Елизавете расцвело монетное дело — изысканная чеканка, редкие тиражи.', + 'Дочь Петра Великого. При Елизавете расцвело монетное искусство — утончённые портреты, безупречная чеканка, редкие тиражи.', ]}, { re: /финлянд/i, texts: [ - 'Русская Финляндия — обособленная серия. Монеты чеканились для Великого княжества и обращались только на его территории.', - 'Монеты Великого княжества Финляндского — компактная серия с преданным кругом ценителей.', + 'Монеты Великого княжества Финляндского — обособленная серия, чеканившаяся для территории, где ходили свои номиналы: пенни и марки с портретами российских императоров. Компактная серия с преданным кругом ценителей.', ]}, { re: /римск|roman|денарий|антониниан/i, texts: [ - 'Рим: легионы, акведуки, Колизей — и монеты, пережившие саму империю. Глобальный рынок растёт на 8-12% в год.', - 'Монета цивилизации, построившей дороги, по которым ходят до сих пор.', + 'Рим: легионы, акведуки, Колизей — и монеты, пережившие империю на два тысячелетия. Каждый денарий — исторический документ, побывавший в руках людей, о которых мы читаем в учебниках.', + 'Монета цивилизации, построившей дороги, по которым ходят до сих пор. Мировой рынок античной нумизматики растёт на 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-х — уцелевшие экземпляры особенно ценны.', + 'Ранний СССР — последний период, когда драгоценный металл ещё ходил в обращении. В 1930-х основной тираж изъяли и переплавили. Уцелевшие экземпляры встречаются всё реже.', + ]}, + { re: /1947/i, texts: [ + '1947 год — одна из загадок советской нумизматики. Монеты отчеканены, но в обращение почти не поступили и были уничтожены. Каждый уцелевший экземпляр — настоящая редкость.', + ]}, + { re: /1958/i, texts: [ + '1958 — монеты несостоявшейся денежной реформы. Почти весь тираж уничтожен. Находка для серьёзного коллекционера.', + ]}, + { re: /полтин/i, materialRe: /серебро|silver/, texts: [ + 'Полтинник — крупный серебряный номинал, один из самых ликвидных в русской нумизматике. Коллекционеры собирают по годам, разновидностям штемпелей и минцмейстерам.', + ]}, + { re: /рубль|рубл/i, materialRe: /серебро|silver/, from: 1700, to: 1917, texts: [ + 'Серебряный рубль Российской империи — «королевский» номинал русской нумизматики. Крупный модуль, детальные портреты, высокое содержание серебра. Стабильный аукционный спрос.', + ]}, + { re: /талер|thaler|taler/i, texts: [ + 'Талер — прародитель доллара, крупная серебряная монета, ходившая по всей Европе. Ликвиден в любой точке мира.', ]}, ] @@ -87,225 +94,147 @@ function generateCoinEmail(coin, details, score) { const name = coin.name || '' const mat = (details.material || '').toLowerCase() const year = details.year_from || 0 - const age = year ? 2026 - year : 0 + const age = year > 0 ? 2026 - year : 0 const price = coin.price || 0 const grade = details.grade || '' const gs = gradeScore(grade) const weightG = parseFloat(details.weight) || 0 + const diameterMm = parseFloat(details.diameter) || 0 const isSilver = /серебро|silver/.test(mat) const isGold = /золото|gold/.test(mat) const isPrecious = isSilver || isGold const h = hash(coin.id || name) - const totalAge = year < 0 ? Math.abs(year) + 2026 : age + const metalWord = isGold ? 'золото' : isSilver ? 'серебро' : '' + const metalAdj = isGold ? 'золотая' : isSilver ? 'серебряная' : '' - const p1 = [] - const p2 = [] - const p3 = [] + const parts = [] - // ══════════ P1: HOOK — литературно + факт ══════════ + // ══════════ АБЗАЦ 1: КРЮЧОК — образ + исторический контекст ══════════ + const hook = [] - if (totalAge > 2000) { - p1.push(pick([ - `${q(name)}. Этой монете более ${totalAge} лет — она старше большинства государств на карте мира. Когда её чеканили, ещё существовала Римская империя.`, - `Представьте: ${totalAge} лет назад кто-то расплатился этой монетой на рыночной площади древнего города. ${q(name)} — подлинный артефакт, переживший тысячелетия.`, - `${q(name)}. ${totalAge} лет истории в одном небольшом кружке металла. Монета, к которой прикасались люди из совершенно другого мира.`, - ], h)) - } else if (totalAge > 400) { - p1.push(pick([ - `${q(name)} — ${totalAge} лет истории. Монета, которая пережила империи, войны и революции, и дошла до наших дней.`, - `${totalAge} лет назад мастер ударил штемпелем по заготовке — так появилась ${q(name)}. С тех пор мир изменился до неузнаваемости.`, - `${q(name)}. ${totalAge} лет — и монета по-прежнему существует. Каждая её потёртость — след чьей-то жизни, давно забытой историей.`, + if (age > 400) { + hook.push(pick([ + `${q(name)}. Этой монете ${age} лет — она старше большинства государств на современной карте. Когда её чеканили, мастер бил штемпелем по раскалённой заготовке вручную, и каждый экземпляр получался неповторимым.`, + `${age} лет назад кто-то расплатился этой монетой. С тех пор рухнули империи, сменились языки и границы — а ${q(name)} дошла до наших дней.`, + `${q(name)} — ${age} лет в одном кружке металла. Каждая потёртость на ней — след чьей-то жизни, давно забытой историей.`, ], h)) } else if (age > 200) { - p1.push(pick([ - `${q(name)} — больше ${Math.floor(age / 100)} столетий истории. Монета из мира без электричества и фотографии, дошедшая до наших дней${gs >= gradeScore('VF') ? ' в достойном состоянии' : ''}.`, - `${q(name)}. ${age} лет назад она была частью чьей-то повседневности. Сегодня — коллекционная ценность.`, - `Больше ${Math.floor(age / 100)} веков назад эта монета зазвенела впервые. ${q(name)} — осколок эпохи, которую мы знаем лишь по книгам.`, + hook.push(pick([ + `${q(name)}. ${year} год — мир без электричества и фотографии. Эта монета помнит то, чего уже не помнит никто из живущих.`, + `Больше двух столетий назад ${q(name)} впервые зазвенела на прилавке. Сегодня она — осколок эпохи, которую мы знаем лишь по книгам.`, + `${year} год. ${q(name)} — монета, пережившая больше двух столетий${gs >= gradeScore('XF') ? ' и дошедшая до нас в прекрасном состоянии' : ''}.`, ], h)) } else if (age > 100) { - p1.push(pick([ - `${q(name)}. ${year} год — мир на пороге грандиозных перемен.${isPrecious ? ` ${isGold ? 'Золотая' : 'Серебряная'} монета, ценная и как металл, и как предмет коллекционирования.` : ''}`, - `${q(name)} — больше века истории. Монета, отчеканенная когда мир был совсем другим.${isPrecious ? ` ${isGold ? 'Золото' : 'Серебро'}.` : ''}`, + hook.push(pick([ + `${q(name)}. ${year} год — мир на пороге перемен, которые изменят всё.`, + `${year} год. ${q(name)} — больше века истории. Монета из совершенно другого мира.`, ], h)) } else { - p1.push(`${q(name)}.${year ? ` ${year} год.` : ''}${isPrecious ? ` ${isGold ? 'Золотая' : 'Серебряная'} монета.` : ''}`) + hook.push(`${q(name)}.${year ? ` ${year} год.` : ''}`) } - // Period context + // Исторический контекст for (const period of PERIODS) { if (!period.re.test(name)) continue if (period.from && year && (year < period.from || year > period.to)) continue if (period.materialRe && !period.materialRe.test(mat)) continue - p1.push(pick(period.texts, h + 7)) + hook.push(pick(period.texts, h + 7)) break } - // ══════════ CONTENT BLOCKS — independent, shuffleable ══════════ - const blocks = [] + parts.push(hook.join(' ')) - // Block: Grade + // ══════════ АБЗАЦ 2: ТЕЛО — грейд + материал + одна деталь ══════════ + const body = [] + + // Грейд — образно, но точно if (gs >= gradeScore('Proof')) { - blocks.push(pick([ - 'Качество Proof — зеркальная поверхность, матовый рельеф. Безупречна в каждой детали.', - 'Proof-чекан: полированные штемпели, идеальная заготовка — совершенство линий.', - 'Proof — высшая категория качества. Зеркальное поле, матовый рельеф.', + body.push(pick([ + 'Качество Proof — зеркальное поле, матовый рельеф. Совершенство, созданное для ценителей, а не для кошелька.', + 'Proof-чекан: полированные штемпели, безупречная заготовка. Каждая линия рисунка передана с фотографической точностью.', ], h + 10)) } else if (gs >= gradeScore('UNC')) { - blocks.push(pick([ - `Сохранность UNC — не была в обращении. Полный штемпельный блеск.${age > 50 ? ` Для ${age}-летней монеты — редкость.` : ''}`, - `UNC — как в день чеканки.${age > 100 ? ` ${age} лет — и ни следа износа.` : ' Ни единого следа времени.'}`, - `Не была в обращении (UNC). Оригинальный блеск, безупречный рельеф.${age > 50 ? ` Через ${age} лет — впечатляет.` : ''}`, + body.push(pick([ + `UNC — монета не была в обращении. Полный штемпельный блеск, нетронутый рельеф.${age > 100 ? ` Через ${age} лет — это почти чудо: значит, кто-то берёг её с самого начала.` : ''}`, + `Сохранность UNC — как в день чеканки.${age > 50 ? ` ${age} лет, и ни единого следа износа. Такое не часто встретишь.` : ' Оригинальный штемпельный блеск.'}`, ], h + 10)) } else if (gs >= gradeScore('AU')) { - blocks.push(pick([ - `AU — почти идеальное состояние. Едва заметные следы на выступающих точках.${age > 100 ? ` Для ${age}-летней монеты — отличный результат.` : ''}`, - `AU — на грани между обращением и совершенством.${age > 100 ? ` Через ${age} лет — это удача.` : ''}`, - `Almost Uncirculated. Минимальный износ.${age > 100 ? ` ${age} лет — и такое состояние.` : ' Блеск сохранён.'}`, + body.push(pick([ + `AU — почти идеальное состояние. Едва заметные следы на самых выступающих точках рельефа, основной блеск сохранён.${age > 100 ? ` Для ${age}-летней монеты — отличный результат.` : ''}`, + `Almost Uncirculated — на грани между обращением и совершенством.${age > 100 ? ` ${age} лет — и такое состояние. Владельцы явно берегли эту монету.` : ' Минимальный износ, блеск на месте.'}`, ], h + 10)) } else if (gs >= gradeScore('XF')) { - blocks.push(pick([ - `XF — чёткий рельеф, все детали выразительны.${age > 200 ? ` Для ${age}-летней монеты — впечатляюще. Большинство ровесников дошли в худшем виде.` : ''}`, - `Extremely Fine.${age > 200 ? ` ${age} лет — а рисунок практически полный.` : ' Лёгкий износ, все элементы на месте.'}`, - `XF — высокая сохранность.${age > 200 ? ` Для монеты ${age}-летней давности — нечастый грейд.` : ''}`, + body.push(pick([ + `XF — чёткий рельеф, все детали выразительны, все надписи читаются без труда.${age > 200 ? ` Для ${age}-летней монеты — впечатляюще. Большинство её ровесников дошли до нас в худшем виде.` : ''}`, + `Extremely Fine — высокая коллекционная сохранность.${age > 200 ? ` Монете ${age} лет, а рисунок практически полный. Нечастый результат.` : ' Лёгкий износ на выступающих частях, все элементы на месте.'}`, ], h + 10)) } else if (gs >= gradeScore('VF')) { - blocks.push(`Сохранность ${grade}.${age > 200 ? ` Для ${age}-летней монеты — достойно.` : ''}`) + body.push(`Сохранность ${grade} — все основные детали различимы, честный коллекционный экземпляр.${age > 200 ? ` Для ${age}-летней монеты — достойно.` : ''}`) } - // Block: Material + melt + // Материал — конкретика, без лишних повторов слова «серебро»/«золото» if (isSilver && weightG > 0 && price > 0) { - const melt = Math.round(weightG * 200) + const melt = Math.round(weightG * getSilverPrice()) const ratio = Math.round(melt / price * 100) if (ratio > 100) { - blocks.push(pick([ - `Серебро (${weightG}г) стоит ~${melt}₽ по курсу ЦБ — а монета продаётся дешевле. Такие аномалии встречаются нечасто.`, - `${weightG}г серебра ≈ ${melt}₽. Стоимость металла превышает стоимость монеты.`, - `Серебра внутри на ${melt}₽ — дороже самой монеты. Арифметика на стороне покупателя.`, + body.push(pick([ + `Внутри — ${weightG}г драгоценного металла на ${melt}₽ по курсу ЦБ. Монета стоит дешевле содержимого. Нумизматическую ценность вы получаете в подарок.`, + `${weightG}г при текущем курсе ≈ ${melt}₽. Стоимость металла превышает цену монеты — нечастая ситуация.`, ], h + 12)) } else if (ratio > 60) { - blocks.push(pick([ - `${weightG}г серебра (~${melt}₽) покрывают ${ratio}% стоимости. Надёжный фундамент.`, - `Серебро (${weightG}г, ~${melt}₽) — ${ratio}% от стоимости. Остальное — коллекционная премия.`, - ], h + 12)) - } else { - blocks.push(`Серебро, ${weightG}г. Драгоценный металл обеспечивает базовую поддержку стоимости.`) + body.push(`${weightG}г драгоценного металла (~${melt}₽) покрывают ${ratio}% стоимости — надёжный фундамент. Монета вряд ли подешевеет ниже стоимости содержимого.`) + } else if (weightG > 2) { + body.push(`${weightG}г${diameterMm ? `, Ø${diameterMm}мм` : ''}. Драгоценный металл обеспечивает ликвидность — такую монету всегда проще продать.`) } - } else if (isSilver) { - blocks.push(pick([ - 'Серебряная монета — ценность, проверенная веками.', - 'Серебро — классика нумизматики.', - 'Серебро обеспечивает ликвидность — драгоценный металл всегда найдёт покупателя.', - ], h + 12)) + } else if (isGold && weightG > 0) { + body.push(`${weightG}г${diameterMm ? `, Ø${diameterMm}мм` : ''}. Двойная природа: нумизматический экземпляр и драгоценный металл. Одно усиливает другое.`) } else if (isGold) { - blocks.push(pick([ - 'Золото — металл, который ценили во все времена и во всех цивилизациях.', - 'Золотая монета — и артефакт, и ценность, проверенная тысячелетиями.', - 'Золото не подвластно времени. Металл королей и императоров.', - ], h + 12)) + body.push('Драгоценный металл, не подвластный времени. Ценность, которая не нуждается в объяснениях.') + } else if (isSilver) { + body.push('Классика нумизматического собирательства. Благородная патина, приятная тактильность, стабильный спрос.') } - // Block: Scarcity + // Одна деталь — тактильность, монетный двор или факт (не больше одной!) + const detailPool = [] + if (age > 300 && !isGold && h % 3 === 0) detailPool.push(pick([ + 'Благородная патина подчёркивает рельеф — такую не подделать, она формируется десятилетиями.', + 'Следы ручной чеканки делают каждый экземпляр уникальным — двух одинаковых не существует.', + ], h + 18)) + if (isSilver && weightG > 3 && h % 3 === 1) detailPool.push(pick([ + `Приятная тяжесть ${weightG} граммов в ладони — ощущение, которое не передать фотографией.`, + `Характерный холод металла в руке${diameterMm ? `, кружок ${diameterMm}мм` : ''} — подлинность чувствуется на ощупь.`, + ], h + 18)) + if (isGold && h % 3 === 1) detailPool.push('Тёплый блеск, который не тускнеет веками. Особенная плотность ощущается сразу — не спутать ни с чем.') + if (/спб|петербург/i.test(name) && h % 3 === 2) detailPool.push('Чеканка Петербургского монетного двора — предприятия, основанного Петром I в 1724 году в Петропавловской крепости.') + if (/сузун/i.test(name) && h % 3 === 2) detailPool.push('Сузунский монетный двор (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) { - blocks.push(pick([ - `Предложение монет ${age > 300 ? 'этой эпохи' : 'этого периода'} сокращается — утраты и оседание в коллекциях делают каждый экземпляр ценнее.`, - `Число сохранившихся экземпляров может только уменьшаться. Через десять лет найти аналог будет сложнее.`, - `Каждый экземпляр, уходящий в коллекцию, сужает предложение. Процесс необратим.`, + closes.push(pick([ + 'Таких монет на рынке с каждым годом меньше — экземпляры уходят в постоянные коллекции и музейные фонды. Процесс необратим: их больше не чеканят.', + `Число доступных экземпляров ${age > 300 ? 'этой эпохи' : 'этого периода'} может только уменьшаться. То, что доступно сегодня, завтра может оказаться в чьей-то постоянной коллекции.`, + 'Предложение сокращается с каждым годом — утраты, оседание в коллекциях, музейные фонды. Обратного хода нет.', ], h + 15)) } else if (age > 100) { - blocks.push(pick([ - 'Монет этого периода на рынке меньше с каждым десятилетием.', - 'Доступных экземпляров всё меньше — их больше не чеканят.', + closes.push(pick([ + 'Столетних монет на рынке не прибавляется. С каждым десятилетием найти достойный экземпляр всё сложнее.', + 'Их не чеканят уже больше века. Каждый экземпляр, ушедший в коллекцию, сужает предложение навсегда.', ], h + 15)) } - // Block: Collector appeal - if (isPrecious && gs >= gradeScore('VF')) { - blocks.push(pick([ - 'Драгоценный металл и хорошая сохранность — сочетание, которое ценилось во все времена.', - 'Металл и состояние — два главных фактора в нумизматике. Оба на месте.', - 'Сохранность и драгоценный металл — то, за чем охотятся коллекционеры.', - 'Коллекционеры ценят прежде всего металл и состояние. Здесь — и то, и другое.', - ], h + 16)) + if (isPrecious && gs >= gradeScore('AU') && !closes.length) { + closes.push('Драгоценный металл в сочетании с высоким грейдом — формула, которая работает на протяжении всей истории нумизматики.') } - // Block: Sensory - if (h % 3 === 0) { - if (age > 300 && !isGold) blocks.push(pick(['Благородная патина подтверждает подлинность и придаёт монете неповторимый характер.', 'Следы ручной чеканки — каждая такая монета уникальна, двух одинаковых не существует.'], h + 18)) - else if (isSilver && weightG > 3) blocks.push(pick(['Приятная тяжесть настоящего серебра в ладони — ощущение, знакомое коллекционерам по всему миру.', 'Характерный холод серебра в руке. Подлинность чувствуется на ощупь.'], h + 18)) - else if (isGold) blocks.push(pick(['Тёплый блеск золота, который не тускнеет веками.', 'Особая тяжесть золота в руке — ощущение, которое ни с чем не спутать.'], h + 18)) - } + if (closes.length) parts.push(closes[0]) - // 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') + return parts.filter(p => p && p.trim()).join('\n\n') } -module.exports = { generateCoinEmail, setGradeScore } +module.exports = { generateCoinEmail, setGradeScore, setSilverPrice } diff --git a/coin-scout/package.json b/coin-scout/package.json index 8684521..95e9e31 100644 --- a/coin-scout/package.json +++ b/coin-scout/package.json @@ -9,6 +9,7 @@ "express": "^4.18.2", "better-sqlite3": "^11.0.0", "node-cron": "^3.0.3", - "node-html-parser": "^6.1.13" + "node-html-parser": "^6.1.13", + "sharp": "^0.33.0" } } diff --git a/coin-scout/public/about.html b/coin-scout/public/about.html new file mode 100644 index 0000000..a7880b5 --- /dev/null +++ b/coin-scout/public/about.html @@ -0,0 +1,230 @@ + + + + + Coin Scout — Как это работает + + + + +

Coin Scout

+

Система автоматического поиска недооценённых монет
в нумизматических интернет-магазинах

+ +

Проблема

+ +

На российском нумизматическом рынке работают десятки интернет-магазинов. В каждом — десятки тысяч позиций. Одна и та же монета в одном магазине может стоить 265₽, а в другом — 5 280₽. При этом характеристики идентичны: тот же год, тот же материал, тот же грейд сохранности.

+ +

Для опытного нумизмата это возможность. Но вручную отслеживать 190 000 позиций в трёх магазинах, сравнивать цены, оценивать перспективность — невозможно физически. Человек способен просмотреть 50–100 монет в день. Система просматривает все 190 000 за минуты.

+ +
+ Суть: Coin Scout ежедневно сканирует три крупнейших нумизматических магазина России, оценивает каждую монету по 8 критериям, находит ценовые аномалии и выдаёт список лучших возможностей — монет, которые стоят дешевле, чем должны. +
+ +

Источники данных

+ +

Система работает с товарными фидами (XML-каталогами) трёх магазинов:

+ +
+ + + + + +
МагазинПозицийСпециализация
numizm.at~62 000Широкий ассортимент: Россия, Европа, Азия. Много мировых монет.
coinsbolhov.ru~37 000Российская империя, СССР, иностранные монеты. Хорошие цены.
numizmat.ru~9 000Премиальный сегмент: Proof, золото, крупные номиналы.
+
+ +

Суммарный охват — более 108 000 уникальных монет. Фиды обновляются ежедневно, система фиксирует появление новых позиций, изменения цен и исчезновение монет (вероятные продажи).

+ +

Как работает система

+ +
1Сканирование фидов. Каждый день (или по кнопке) система загружает XML-каталоги всех трёх магазинов. Из каждого товара извлекаются: название, цена, старая цена, ссылка, изображение, наличие. Для надёжности используется дисковый кеш — если магазин не отвечает, берутся данные предыдущего скана.
+ +
2Парсинг деталей. Из фида и со страниц товаров извлекаются характеристики: грейд (сохранность), материал, вес, диаметр, год чеканки, страна. Для монет, где фид не даёт деталей, система автоматически заходит на страницу товара и парсит таблицу характеристик.
+ +
3Обогащение данных. Если магазин не указал материал или страну, система определяет их из названия: «2 копейки 1909 года СПБ» → Россия, Медь. Год извлекается с валидацией (не путая каталожные номера с датами).
+ +
4Скоринг. Каждая монета получает числовую оценку от 0 до 100 баллов по 8 критериям (подробнее — в следующем разделе). Монеты сортируются по скору: чем выше — тем интереснее.
+ +
5Кросс-магазинное сравнение. Система находит одинаковые монеты в разных магазинах (совпадение по названию, грейду и материалу) и показывает разницу в цене. Это позволяет купить монету там, где она дешевле.
+ +
6Отслеживание динамики. При каждом скане фиксируется цена каждой монеты. Со временем накапливается история: можно увидеть, когда магазин снизил цену, и купить на просадке.
+ +

Скоринг: 8 критериев оценки

+ +

Система оценки основана на анализе 40+ профессиональных источников по нумизматике: PCGS, NGC, Forbes.ru, numisdon.com, CoinWeek, Raritetus и др.

+ +
+

1. Сохранность / Грейд (до 30 баллов)

+

Главный фактор стоимости монеты. Каждый шаг грейда может увеличить цену в 2–50 раз. Proof = 28, UNC = 25, AU = 20, XF = 15, VF = 8 баллов. Бонус за исключительную сохранность для возраста: VF+ для монеты до 1800 года — это редкость.

+
+ +
+

2. Материал и стоимость металла (до 25 баллов)

+

Драгоценный металл создаёт «пол» стоимости — монета не может стоить дешевле содержащегося в ней металла. Золото = 22, серебро = 14 баллов. Если монета стоит дешевле стоимости серебра внутри неё (melt value) — это +10 дополнительных баллов. Цена серебра обновляется ежедневно с сайта ЦБ РФ.

+
+ +
+

3. Возраст (до 20 баллов)

+

Чем старше — тем меньше сохранившихся экземпляров. До н.э. = 20, 500+ лет = 18, 300+ = 14, 200+ = 10, 100+ = 6 баллов. Античные монеты показывают 8–15% годового роста.

+
+ +
+

4. Российские премиум-периоды (до 15 баллов)

+

Отдельные периоды русской нумизматики обладают повышенным потенциалом: монеты 1947 и 1958 годов (не поступили в обращение), Смутное время (1610–1612), раннее советское серебро (1921–1931), монеты Николая II, Петра I, Екатерины II.

+
+ +
+

5. Мировые монеты (до 10 баллов)

+

Бонусы за перспективные направления мировой нумизматики: Древняя Греция, Рим, Византия, Боспорское царство, Османская империя, талеры, панды, соверены.

+
+ +
+

6. Ошибки чеканки и разновидности (до 15 баллов)

+

Монеты с браком — отдельная ценная категория. Мул / двойной аверс (+15), брак чеканки (+12), перечекан (+10), серия ЧЯП (+10), отсутствие знака монетного двора (+8). Система автоматически распознаёт браки по названию.

+
+ +
+

7. Ценовая эффективность (до 12 баллов)

+

Бонус за выгодную цену: скидка ≥30% от старой цены (+8), AU+ дешевле 500₽ (+6), UNC до 1000₽ (+4). Чем дешевле монета хорошей сохранности — тем больше бонус.

+
+ +
+

8. Штрафы (до −20 баллов)

+

Снижение скора за негативные факторы: копии (−20), массовые юбилейные СССР (−12), чищеные монеты (−10), современные памятные ЦБ без драгмета (−8). Система фильтрует не-монеты: облигации, марки, аксессуары.

+
+ +

Ключевые механики поиска выгоды

+ +

Механика 1: Арбитраж между магазинами

+ +

Одна и та же монета продаётся в разных магазинах по существенно разным ценам. Coin Scout сравнивает цены только для монет с одинаковым грейдом и материалом — чтобы исключить ложные совпадения.

+ +
+ Реальный пример: «2 копейки 1909 года СПБ», медь, VF.
+ numizm.at — 5 280₽. coinsbolhov.ru — 265₽.
+ Разница: ×20. Одна и та же монета, одинаковый грейд, одинаковый материал, одинаковый тираж. +
+ +

Причины ценовых расхождений: разные методы ценообразования, разная оборачиваемость, разные целевые аудитории магазинов. Для покупателя это — окно возможности.

+ +

Механика 2: Монеты дешевле стоимости металла

+ +

Иногда серебряная монета продаётся дешевле стоимости содержащегося в ней серебра. Система рассчитывает melt value (вес × текущая цена серебра по ЦБ) и находит такие аномалии.

+ +
+ Пример: Монета весом 2.7г серебра. Серебро по курсу ЦБ: 188₽/г. Стоимость металла: 508₽. Цена монеты: 500₽.
+ Вы покупаете серебро дешевле рынка, а нумизматическую ценность получаете в подарок. +
+ +

Механика 3: Мониторинг снижений цен

+ +

Система фиксирует историю цен при каждом скане. Когда магазин снижает цену — монета попадает в раздел «Снижения цен» на дашборде. Покупка на просадке — одна из базовых стратегий.

+ +

Механика 4: Высокий скор при низкой цене

+ +

Скоринг учитывает все факторы ценности: грейд, металл, возраст, историческую значимость, редкость. Монета со скором 60+ и ценой до 1000₽ — это потенциально недооценённый экземпляр. Система автоматически сортирует по скору и позволяет фильтровать по цене, материалу, стране и магазину.

+ +

Механика 5: Обнаружение браков

+ +

Монеты с ошибками чеканки (смещение, раскол штемпеля, двойной удар, перечекан, мул) — отдельная и высоко ценимая категория. Они часто продаются по обычной цене, потому что продавец не осознаёт редкость. Система автоматически распознаёт браки по названию и помечает их оранжевым тегом.

+ +

Функции панели управления

+ +
+

Горячие монеты

+

Основной экран. Все доступные монеты, отсортированные по скору. Фильтры: максимальная цена, минимальный грейд, материал, страна, магазин, наличие, дедупликация. Для каждой монеты: подробный анализ с разбивкой по факторам, текст для рассылки, история цен.

+
+ +
+

Сравнение магазинов

+

Таблица одинаковых монет в разных магазинах с разницей в цене. Колонки: грейд, материал, скор, цены в обоих магазинах, процент разницы. Сортировка по разнице — самые выгодные арбитражные возможности наверху.

+
+ +
+

Дашборд

+

Общая аналитика: количество монет, динамика за неделю, текущая цена серебра (ЦБ РФ), статистика парсинга, график новых монет по дням, топ-находки недели, снижения и повышения цен, исчезнувшие монеты (вероятные продажи), распределение по материалам и грейдам.

+
+ +
+

История цен

+

Для каждой монеты доступен график изменения цены. Позволяет увидеть тренд: монета дорожает (спрос растёт) или дешевеет (возможность для покупки).

+
+ +
+

Автоматизация

+

Ежедневное сканирование по расписанию (настраиваемый час). Автоматическое обновление цены серебра с ЦБ РФ. Автопарсинг деталей для монет без характеристик. Дисковый кеш фидов для отказоустойчивости.

+
+ +

Стратегия использования

+ +
1Ежедневный мониторинг. Открывайте дашборд — смотрите снижения цен и топ-находки недели. Если появилась монета с высоким скором и низкой ценой — это сигнал.
+ +
2Арбитраж. Вкладка «Сравнение» — находите монеты, которые в одном магазине стоят значительно дешевле. Проверяйте грейд и фото на сайтах обоих магазинов.
+ +
3Серебро ниже melt. Фильтр «Серебро» + сортировка по скору — монеты, у которых стоимость металла близка к цене или превышает её, помечены в анализе.
+ +
4Браки и разновидности. Оранжевые теги «Брак», «Мул», «Перечекан» — это монеты, которые могут стоить значительно дороже, чем указано в магазине.
+ +
5Диверсификация. Используйте фильтр по стране, чтобы распределить покупки между разными направлениями: Россия, Европа, античность.
+ +
+ Ключевой принцип: покупайте самую редкую монету в лучшем состоянии за минимальную цену. Coin Scout автоматизирует поиск именно таких совпадений среди 108 000+ позиций. +
+ +

Технические параметры

+ +
+ + + + + + + + + + + + +
ПараметрЗначение
Охват3 магазина, 108 000+ позиций
Частота сканированияЕжедневно (настраиваемый час) + ручной запуск
Время полного скана3–5 минут
Скоринг8 критериев, 0–100 баллов
Критерии сравненияНазвание + грейд + материал
Цена серебраАвтоматически с ЦБ РФ, ежедневно
Определение стран38 паттернов, автоматически из названия
Детекция браков6 типов: брак, мул, перечекан, разновидность, пробная, новодел
База данныхSQLite, история цен, детали, лог сканов
РазвёртываниеDocker, один контейнер, порт 5180
+
+ +

Coin Scout · Версия апрель 2026
Система является аналитическим инструментом. Решение о покупке всегда принимает человек.
Всегда проверяйте монету лично или по фото перед покупкой.

+ + + + diff --git a/coin-scout/public/index.html b/coin-scout/public/index.html index 3221175..fdbfc0a 100644 --- a/coin-scout/public/index.html +++ b/coin-scout/public/index.html @@ -49,6 +49,7 @@ .coin-meta .tag.grade-high { background: #1a4731; color: #56d364; } .coin-meta .tag.grade-mid { background: #3d2e00; color: #e3b341; } .coin-meta .tag.no-stock { background: #490202; color: #f85149; } + .coin-meta .tag.special { background: #3d1f00; color: #f0883e; font-weight: 600; } .coin-price { font-size: 16px; font-weight: 600; color: #56d364; margin-top: 6px; } .coin-price .old { font-size: 12px; color: #8b949e; text-decoration: line-through; margin-left: 6px; font-weight: 400; } .coin-score { font-size: 11px; color: #f0883e; font-weight: 600; margin-top: 4px; } @@ -83,8 +84,10 @@ .analysis-text { color: #c9d1d9; font-size: 13px; line-height: 1.6; white-space: pre-line; } .analysis-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; } .analysis-tags .tag { background: #21262d; padding: 3px 8px; border-radius: 4px; font-size: 11px; color: #8b949e; } - .coin-detail-btn { background: none; border: 1px solid #30363d; color: #58a6ff; font-size: 11px; padding: 2px 8px; border-radius: 4px; cursor: pointer; margin-top: 4px; } - .coin-detail-btn:hover { background: #21262d; } + .coin-detail-btn, .coin-history-btn { background: none; border: 1px solid #30363d; color: #58a6ff; font-size: 11px; padding: 2px 8px; border-radius: 4px; cursor: pointer; margin-top: 4px; } + .coin-detail-btn:hover, .coin-history-btn:hover { background: #21262d; } + .sparkline { margin-top: 4px; } + .history-chart { margin-top: 12px; } .progress-panel { background: #1c2128; border: 1px solid #30363d; border-radius: 8px; padding: 16px; margin-bottom: 16px; display: none; } .progress-panel.active { display: block; } @@ -138,6 +141,11 @@ +