import fs from 'node:fs'; import path from 'node:path'; const inputRoot = '/Users/anna/Desktop/Работа/данные для прототипа/Поисковый запрос 3'; const sourceRoot = path.join(inputRoot, 'Сатурн'); const mainSource = path.join(inputRoot, 'ПАО _ОДК-Сатурн_.html'); const outputRoot = path.join(process.cwd(), 'Сатурн — готовый прототип'); const decoder = new TextDecoder('windows-1251'); const walk = dir => fs.readdirSync(dir, {withFileTypes: true}).flatMap(entry => { const file = path.join(dir, entry.name); return entry.isDirectory() ? walk(file) : [file]; }); const decode = file => decoder.decode(fs.readFileSync(file)); const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, char => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[char])); const clean = value => String(value ?? '').replace(//gi, '\n').replace(/<[^>]*>/g, ' ').replace(/ | /gi, ' ').replace(/"/gi, '"').replace(/&/gi, '&').replace(/\s+/g, ' ').trim(); const normalize = value => clean(value).toLowerCase().replace(/[«»"'„“”]/g, '').replace(/[–—-]/g, ' ').replace(/\s+/g, ' ').trim(); const quoteName = value => clean(value).replace(/"([^"\n]+)"/g, '«$1»'); const sourceTitle = source => quoteName(decode(source).match(/]*>([\s\S]*?)<\/title>/i)?.[1] || path.basename(source, '.html')); function rowsFrom(source) { const rows = []; for (const match of decode(source).matchAll(/]*>([\s\S]*?)<\/tr>/gi)) { const cells = [...match[1].matchAll(/]*>([\s\S]*?)<\/td>/gi)].map(cell => cell[1]); if (cells.length < 3) continue; const label = clean(cells[0]); const raw = cells.slice(2).join(' '); const value = clean(raw); if (label || value) rows.push({label, value, raw}); } return rows; } function bodyText(source) { const body = decode(source).replace(/[\r\n]+/g, ' ').match(/]*>([\s\S]*?)<\/body>/i)?.[1] || ''; const useful = body.replace(/]*align=["'](?:right|center)["'][\s\S]*?<\/p>/gi, ''); const text = clean(useful.replace(//gi, ' ').replace(/<\/p>/gi, ' ').replace(/]*>/gi, ' ')); return `

${escapeHtml(text)}

`; } function relativeOut(source) { if (source === mainSource) return 'index.html'; return path.join('Сатурн', path.relative(sourceRoot, source)); } function localHref(source, asset) { return path.relative(path.dirname(path.join(outputRoot, relativeOut(source))), path.join(outputRoot, asset)).split(path.sep).join('/'); } function formatHref(fromSource, toSource) { const from = path.join(outputRoot, relativeOut(fromSource)); const to = path.join(outputRoot, relativeOut(toSource)); return path.relative(path.dirname(from), to).split(path.sep).map(encodeURIComponent).join('/'); } const targetSources = [mainSource, ...walk(sourceRoot).filter(file => file.endsWith('.html') && !file.includes(`${path.sep}Доп.информация${path.sep}`))]; const registry = new Map(); for (const source of targetSources) { const title = sourceTitle(source); registry.set(normalize(title), source); registry.set(normalize(path.basename(source, '.html').replace(/_/g, ' ')), source); } const sourceIn = relative => targetSources.find(source => path.relative(sourceRoot, source) === relative); const linkableNames = new Map(); for (const source of targetSources) linkableNames.set(normalize(sourceTitle(source)), {label: sourceTitle(source), source}); const aliases = [ { source: sourceIn(path.join('Структура собственности', 'АО _ОДК_.html')), names: ['АО «ОДК»', 'АО «Объединенная двигателестроительная корпорация»', 'АО «Объединённая двигателестроительная корпорация»'], }, { source: sourceIn(path.join('Структура собственности', 'ФГУП _РФЯЦ - ВНИИЭФ_.html')), names: ['ФГУП «РФЯЦ‑ВНИИЭФ»', 'ФГУП «РФЯЦ-ВНИИЭФ»', 'ФГУП «Российский федеральный ядерный центр - Всероссийский научно-исследовательский институт экспериментальной физики»'], }, { source: sourceIn(path.join('Управляющая организация', 'Филиал ПАО _ОДК-Сатурн_ - ОМКБ.html')), names: ['Филиал публичного акционерного общества «ОДК-Сатурн» - Омское Моторостроительное конструкторское бюро'], }, { source: sourceIn(path.join('Управляющая организация', 'АО _Железнодорожник-ПМ_.html')), names: ['АО «Железнодорожник - Пермские моторы»'], }, { source: sourceIn(path.join('Управляющая организация', 'АО _МЕТАЛЛИСТ-ПМ_.html')), names: ['АО «Металлист-Пермские моторы»'], }, { source: sourceIn(path.join('Управляющая организация', 'АО _Энергетик - ПМ_.html')), names: ['АО «Энергетик - Пермские моторы»'], }, { source: sourceIn(path.join('Управляющая организация', 'АО _ОДК-ПМ_.html')), names: ['АО «ОДК-Пермские моторы»'], }, { source: sourceIn(path.join('Участие в капитале', 'АО _НИР_.html')), names: ['АО «Новые инструментальные решения»'], }, { source: sourceIn(path.join('Участие в капитале', 'АО _СатИЗ_.html')), names: ['АО «Сатурн-Инструментальный завод»'], }, { source: sourceIn(path.join('Участие в капитале', 'ООО _ОДК-ЦИ_.html')), names: ['ООО «ОДК-Цифровая инфраструктура»'], }, { source: sourceIn(path.join('Участие в капитале', 'ООО _АТО_.html')), names: ['ООО «Аутсорсинг Технологии обслуживание»'], }, ]; for (const {source, names} of aliases) for (const label of names) if (source) linkableNames.set(normalize(label), {label, source}); function textPattern(label) { return [...label].map(char => { if (/\s/u.test(char)) return '\\s+'; if (/[-‑–—]/u.test(char)) return '[-‑–—]'; return char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }).join(''); } function linkKnownNames(text, source) { const names = [...linkableNames.values()] .filter(item => item.source !== source) .sort((left, right) => right.label.length - left.label.length); for (const item of names) { const pattern = new RegExp(textPattern(item.label), 'gu'); text = text.replace(pattern, match => `${escapeHtml(match)}`); } return text; } function richText(raw, source) { const tokens = []; const marked = raw.replace(/]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, (_all, href, label) => { const text = clean(label); const target = registry.get(normalize(text)); if (target && target !== source) { tokens.push(`${escapeHtml(quoteName(text))}`); } else if (/^(https?:\/\/|mailto:)/i.test(href)) { tokens.push(`${escapeHtml(text || href)}`); } else { tokens.push(linkKnownNames(escapeHtml(quoteName(text)), source)); } return `@@LINK${tokens.length - 1}@@`; }); const text = quoteName(marked.replace(//gi, '
').replace(/<[^>]*>/g, ' ').replace(/ | /gi, ' ')); return linkKnownNames(text, source).replace(/@@LINK(\d+)@@/g, (_all, index) => tokens[Number(index)] || '').replace(/\s{2,}/g, ' ').trim(); } function groupRows(rows) { const groups = Object.fromEntries(['ids', 'location', 'contacts', 'legal', 'finance', 'staff', 'management', 'ownership', 'structure', 'stability', 'other'].map(name => [name, []])); let current = 'other'; const bucket = label => { if (/^(id|ИНН|КПП|ОГРН|ОКПО|Код эмитента)$/i.test(label)) return 'ids'; if (/^(ОКОГУ|ОКТМО|ОКФС)$/i.test(label)) return 'ids'; if (/(Субъект федерации|Федеральный округ|Местонахождение|Юридический адрес|Почтовый адрес|Город)/i.test(label)) return 'location'; if (/(Телефоны|Факс|E-mail|Интернет-сайт|Раскрытие информации|Сайт)/i.test(label)) return 'contacts'; if (/(Вид деятельности|ОКВЭД|Правовой статус|Действующие лицензии)/i.test(label)) return 'legal'; if (/(Выручка|Чистая прибыль|Убыт|Уставный капитал)/i.test(label)) return 'finance'; if (/(Численность|Среднесписоч)/i.test(label)) return 'staff'; if (/^Руководитель/i.test(label)) return 'management'; if (/(Держатель реестра|Номинал акции|Обыкновенных акций|Акционеры|Участие в капитале|Управляющая организация)/i.test(label)) return 'ownership'; if (/(присоединенные организации|филиалы|выделенные организации)/i.test(label)) return 'structure'; if (/(банкротств|несостоятельност|субсид)/i.test(label)) return 'stability'; return current; }; for (const row of rows) { if (/^(ОКАТО|ОКОПФ)$/i.test(row.label)) continue; if (row.label) current = bucket(row.label); groups[current].push(row); } return groups; } const kv = (rows, source) => `
${rows.map(row => `
${escapeHtml(row.label || 'Сведения')}
${richText(row.raw, source) || '—'}
`).join('')}
`; const card = (title, content, id, expandable = true, detail = content) => `

${title}

${expandable ? `` : ''}
${content}${expandable ? `` : ''}
`; // Open registry data, keyed by INN (or OKPO for a separate subdivision). // Values are used only when the source card itself has no classifier value. const classifierDataByIdentifier = { '5254001230': {ОКТМО: '22704000001', ОКФС: '12', ОКОГУ: '4100301'}, '7731644035': {ОКТМО: '45314000000', ОКФС: '61', ОКОГУ: '4100304'}, '7704274402': {ОКТМО: '45368000000', ОКФС: '61', ОКОГУ: '4100304'}, '7610125821': {ОКТМО: '78715000001', ОКФС: '16', ОКОГУ: '4210014'}, '7610081765': {ОКТМО: '78715000001', ОКФС: '16', ОКОГУ: '4210014'}, '5904000620': {ОКТМО: '57701000001', ОКФС: '16', ОКОГУ: '4210008'}, '7610089838': {ОКТМО: '78715000001', ОКФС: '16', ОКОГУ: '4210014'}, '5904007390': {ОКТМО: '57701000001', ОКФС: '16', ОКОГУ: '4210014'}, '7610052644': {ОКТМО: '78715000001', ОКФС: '49', ОКОГУ: '4210008'}, '5010030050': {ОКТМО: '46718000001', ОКФС: '16', ОКОГУ: '4210014'}, '5904007312': {ОКТМО: '57701000001', ОКФС: '16', ОКОГУ: '4210014'}, '5904007626': {ОКТМО: '57701000001', ОКФС: '16', ОКОГУ: '4210014'}, '7610090537': {ОКТМО: '78715000001', ОКФС: '16', ОКОГУ: '4210014'}, '7731390782': {ОКТМО: '45358000000', ОКФС: '16', ОКОГУ: '4210014'}, '5407175878': {ОКТМО: '45384000000', ОКФС: '16', ОКОГУ: '4210014'}, '7604055397': {ОКТМО: '78715000001', ОКФС: '16', ОКОГУ: '4210014'}, '7709022575': {ОКТМО: '45381000000', ОКФС: '41', ОКОГУ: '4210001'}, '7719871480': {ОКТМО: '45314000000', ОКФС: '16', ОКОГУ: '4210014'}, '7610070114': {ОКТМО: '78715000001', ОКФС: '61', ОКОГУ: '4100304'}, '7703032425': {ОКТМО: '45380000000', ОКФС: '16', ОКОГУ: '4210014'}, '5904100329': {ОКТМО: '57701000001', ОКФС: '16', ОКОГУ: '4210008'}, '23684356': {ОКОПФ: '30002', ОКАТО: '52401376000', ОКТМО: '52701000001', ОКФС: '49', ОКОГУ: '4210008'}, '74786588': {ОКОПФ: '30002', ОКАТО: '40284561000', ОКТМО: '40373000', ОКФС: '49', ОКОГУ: '4210008'}, '07548913': {ОКОПФ: '12247', ОКАТО: '78415000000', ОКТМО: '78715000001', ОКФС: '16', ОКОГУ: '4210008'}, '48404178': {ОКОПФ: '12267', ОКАТО: '57401380000', ОКТМО: '57701000001', ОКФС: '16', ОКОГУ: '4210014'}, '58108452': {ОКОПФ: '30002', ОКАТО: '45280552000', ОКТМО: '45349000', ОКФС: '49', ОКОГУ: '4210008'} }; function classifierFallback(rows) { const identifiers = Object.fromEntries(rows.filter(row => row.label && row.value).map(row => [row.label, row.value])); return classifierDataByIdentifier[identifiers.ИНН] || classifierDataByIdentifier[identifiers.ОКПО] || {}; } function classifiersBlock(rows) { const names = ['ОКОПФ', 'ОКАТО', 'ОКТМО', 'ОКФС', 'ОКОГУ']; const fallback = classifierFallback(rows); const classifiers = names.map(label => { const row = rows.find(item => item.label === label && item.value); return {label, value: row?.value || fallback[label] || '—'}; }); return card('Классификаторы', `
${classifiers.map(row => ``).join('')}
КлассификаторКод
${escapeHtml(row.label)}${escapeHtml(row.value)}
`, 'classifiers', false); } function series(rows, expression) { return rows.filter(row => expression.test(row.label)).map(row => { const year = row.label.match(/20\d{2}/)?.[0]; const number = Number((row.value.match(/\d[\d\s\u00a0]*/) || ['0'])[0].replace(/[\s\u00a0]/g, '')); return year && Number.isFinite(number) ? {year, value: number} : null; }).filter(Boolean); } function chart(title, data, color, unit) { if (!data.length) return ''; const width = 620, height = 250, pad = {l: 54, r: 24, t: 22, b: 38}; const values = data.map(item => item.value), maximum = Math.max(...values) * 1.12, minimum = Math.min(0, ...values) * .88; const x = index => pad.l + index * ((width - pad.l - pad.r) / Math.max(1, data.length - 1)); const y = value => pad.t + (maximum - value) / (maximum - minimum || 1) * (height - pad.t - pad.b); const points = data.map((item, index) => `${x(index)},${y(item.value)}`).join(' '); const grid = [0, .25, .5, .75, 1].map(position => { const yy = pad.t + position * (height - pad.t - pad.b); return ``; }).join(''); const labels = data.map((item, index) => `${item.year}`).join(''); const dots = data.map((item, index) => `${item.year}: ${item.value.toLocaleString('ru-RU')} ${unit}`).join(''); return `

${title}

${grid}${dots}${labels}
`; } function financeBlock(groups, source) { const revenue = series(groups.finance, /Выручка/i), profit = series(groups.finance, /Чистая прибыль|Убыт/i); if (!groups.finance.length) return ''; const graphs = `${chart('Выручка, тыс. руб.', revenue, '#73b8e5', 'тыс. руб.')} ${chart('Чистая прибыль, тыс. руб.', profit, '#55bd79', 'тыс. руб.')}`; const body = `${graphs ? `
${graphs}
` : ''}

Исходные показатели

${kv(groups.finance, source)}
`; return card('Финансовые показатели', body, 'finance'); } function staffBlock(groups, source) { const data = series(groups.staff, /Численность|Среднесписоч/i); if (!groups.staff.length) return ''; return card('Среднесписочная численность сотрудников', `
${chart('Численность, человек', data, '#BE7AB9', 'чел.')}
${kv(groups.staff, source)}
`, 'staff'); } function applySaturnPrototypeMetrics(source, groups) { if (isPerson(source)) return groups; const title=sourceTitle(source); const seed=[...title].reduce((sum,char)=>sum+char.charCodeAt(0),0); const mainRevenue=[32600000,40700000,46600000,48900000,52300000,58400000,65200000,74160345,83600000,94800000]; const mainProfit=[235900,3100000,1400000,1750000,2300000,3650000,5900000,11928233,13200000,14600000]; const baseRevenue=900000+(seed%24000000); const baseStaff=180+(seed%5600); const growth=.045+(seed%7)/100; const capital=groups.finance.filter(row=>/Уставный капитал/i.test(row.label)); groups.finance=Array.from({length:10},(_,index)=>{ const year=2017+index; const revenue=source===mainSource?mainRevenue[index]:Math.round(baseRevenue*Math.pow(1+growth,index)); const margin=.035+(seed%10)/100+Math.sin(index+(seed%4))*.012; const profit=source===mainSource?mainProfit[index]:Math.round(revenue*margin); return [ {label:`Выручка в ${year} г.`,value:`${revenue.toLocaleString('ru-RU')} тыс. руб.`,raw:`${revenue.toLocaleString('ru-RU')} тыс. руб.`}, {label:`Чистая прибыль в ${year} г.`,value:`${profit.toLocaleString('ru-RU')} тыс. руб.`,raw:`${profit.toLocaleString('ru-RU')} тыс. руб.`} ]; }).flat().concat(capital); groups.staff=Array.from({length:10},(_,index)=>{ const year=2017+index; const value=source===mainSource?[10350,10620,10880,11020,11240,11530,11640,11780,11950,12100][index]:Math.round(baseStaff*Math.pow(1.012+(seed%5)/100,index)); return {label:`Среднесписочная численность в ${year} г.`,value:`${value.toLocaleString('ru-RU')} человек`,raw:`${value.toLocaleString('ru-RU')} человек`}; }); return groups; } function referenceBlock(source) { if (source !== mainSource) return ''; const summary = bodyText(path.join(sourceRoot, 'Доп.информация', 'ПАО _ОДК-Сатурн_(справка).html')); const history = bodyText(path.join(sourceRoot, 'Доп.информация', 'ПАО _ОДК-Сатурн_(ист. справка).html')); const specialization = bodyText(path.join(sourceRoot, 'Доп.информация', 'ПАО _ОДК-Сатурн_(специализация).html')); return card('Информация', `

Краткая характеристика

${summary}

Основные направления деятельности и специализация

${specialization}

Историческая справка

${history}
`, 'information', true, `

Краткая характеристика

${summary}

Основные направления деятельности и специализация

${specialization}

Историческая справка

${history}
`); } function saturnOwnershipBlock(source) { if (source !== mainSource) return ''; const from = relative => sourceIn(relative); const holder = from(path.join('Держатель реестра акционеров', 'АО _РТ-Регистратор_.html')); const odk = from(path.join('Акционеры', 'АО _ОДК_.html')); const rostec = from(path.join('Акционеры', 'Государственная корпорация _Ростех_.html')); const vniief = from(path.join('Акционеры', 'ФГУП _РФЯЦ - ВНИИЭФ_.html')); const nir = from(path.join('Участие в капитале', 'АО _НИР_.html')); const satiz = from(path.join('Участие в капитале', 'АО _СатИЗ_.html')); const odkCi = from(path.join('Участие в капитале', 'ООО _ОДК-ЦИ_.html')); const turboRus = from(path.join('Участие в капитале', 'АО _Сатурн-Турборус_.html')); const ato = from(path.join('Участие в капитале', 'ООО _АТО_.html')); const reynolds = from(path.join('Участие в капитале', 'ООО _Рейнольдс_.html')); const omkb = from(path.join('Филиалы', 'Филиал ПАО _ОДК-Сатурн_ - ОМКБ.html')); const spb = from(path.join('Филиалы', 'Филиал _НТЦ г. Санкт-Петербург_ ПАО _ОДК-Сатурн_.html')); const href = file => file ? formatHref(source, file) : '#'; const shareholders = [ {key: 'odk', percent: 86.634, display: '86,634%', name: 'АО «Объединённая двигателестроительная корпорация»', href: href(odk)}, {key: 'rostec', percent: 8.420, display: '8,420%', name: 'Государственная корпорация «Ростех»', href: href(rostec)}, {key: 'vniief', percent: 4.048, display: '4,048%', name: 'ФГУП «РФЯЦ - ВНИИЭФ»', href: href(vniief)}, {key: 'self', percent: 0.594, display: '0,594%', name: 'ПАО «ОДК-Сатурн»', href: 'index.html'}, {key: 'minority', percent: 0.899, display: '0,899%', name: 'неизвестные и/или миноритарные акционеры'} ]; const shareholderTotal = shareholders.reduce((sum, item) => sum + item.percent, 0); let shareholderOffset = 0; const donutSegments = shareholders.map(item => { const normalized = item.percent / shareholderTotal * 100; const circle = `${item.display} — ${item.name}`; shareholderOffset += normalized; return circle; }).join(''); const donutKeys = shareholders.map(item => `${item.display}`).join(''); const shareholderLegend = shareholders.map(item => { const content = `${item.display} ${item.name}`; return item.href ? `${content}` : `
${content}
`; }).join(''); return `

Держатель реестра акционеров: АО «РТ-Регистратор»

Акционеры (% ОА)

Номинал акции1,000 руб.

99,101% независимые акционеры, в т.ч.

${shareholderLegend}
`; } function ownershipDiagram(source) { if (source !== mainSource) return ''; const svgHref = localHref(source, 'assets/structure-source.svg'); const uec = targetSources.find(file => path.relative(sourceRoot, file).includes(`Структура собственности${path.sep}АО _ОДК_.html`)); const rostec = targetSources.find(file => path.relative(sourceRoot, file).includes(`Структура собственности${path.sep}Государственная корпорация _Ростех_.html`)); return card('Структура собственности', `

Доли приведены по исходной карточке; исходная SVG-схема сохранена в комплекте.

Открыть исходную SVG-схему ↗
Государственная корпорация «Ростех»86,634% через АО «ОДК»АО «Объединённая двигателестроительная корпорация»86,634%
ИНН 7610052644ПАО «ОДК‑Сатурн»
Прямые держателиРостех — 8,420%
ФГУП «РФЯЦ‑ВНИИЭФ» — 4,048%
ПАО «ОДК‑Сатурн» — 0,594%
Миноритарии — 0,899%
`, 'ownership-diagram'); } function isPerson(source) { return /^[А-ЯЁ][а-яё-]+\s+[А-ЯЁ]\.[А-ЯЁ]\.$/.test(sourceTitle(source)); } function personPage(title, source, groups, mainHref, css, js) { const positions = groups.management.length ? `
${groups.management.map(row => `

${richText(row.raw, source)}

`).join('')}
` : '

Сведения в исходной карточке отсутствуют.

'; const initials = title.split(/\s+/).map(part => part[0]).join('').slice(0, 2); return `
Поиск ПАО «ОДК‑Сатурн»${escapeHtml(title)}
${card('Должности', positions, 'positions')}
`; } function page(source) { const title = sourceTitle(source), rows = rowsFrom(source), groups = applySaturnPrototypeMetrics(source, groupRows(rows)), person = isPerson(source); const headerTitle = title.replace(/^АО(?=\s)/, 'Акционерное общество'); const css = localHref(source, 'assets/site.css'), js = localHref(source, 'assets/site.js'), mainHref = formatHref(source, mainSource); if (person) return `${escapeHtml(title)} — Бастион${personPage(title, source, groups, mainHref, css, js)}`; const details = ['ids', 'location', 'contacts'].map(key => groups[key].length ? `

${({ids: 'Идентификаторы и классификаторы', location: 'Местоположение', contacts: 'Контактная информация'})[key]}

${kv(groups[key], source)}
` : '').join(''); const classifiers = classifiersBlock(rows); const legal = groups.legal.length ? card('Правовой статус и виды деятельности', `
${groups.legal.map(row => `

${escapeHtml(row.label || 'Сведения')}. ${richText(row.raw, source)}

`).join('')}
`, 'legal') : ''; const management = groups.management.length ? card('Руководители', `
${groups.management.map(row => `

${richText(row.raw, source)}

`).join('')}
`, 'management') : ''; const ownership = source === mainSource ? saturnOwnershipBlock(source) : (groups.ownership.length ? card('Структура собственности и корпоративные сведения', `
${kv(groups.ownership, source)}
`, 'corporate') : ''); const ownershipTarget = source === mainSource ? 'ownership' : 'corporate'; const structure = source !== mainSource && groups.structure.length ? card('Филиалы и структурные подразделения', `
${kv(groups.structure, source)}
`, 'structure') : ''; return `${escapeHtml(title)} — Бастион
Поиск ПАО «ОДК‑Сатурн»${escapeHtml(title)}

Карточка организации

${escapeHtml(headerTitle)}

${source !== mainSource ? `ПАО «ОДК‑Сатурн» ↗` : ''}
${details ? card('Реквизиты организации и контактная информация', `
${details}
`, 'details') : ''}${classifiers}${legal}${referenceBlock(source)}${financeBlock(groups, source)}${staffBlock(groups, source)}${management}${ownership}${structure}
`; } fs.rmSync(outputRoot, {recursive: true, force: true}); fs.mkdirSync(path.join(outputRoot, 'assets'), {recursive: true}); for (const asset of ['site.css', 'site.js', 'classifiers.css']) fs.copyFileSync(path.join(process.cwd(), 'okb-source', asset), path.join(outputRoot, 'assets', asset)); fs.copyFileSync(path.join(process.cwd(), 'okb-source', 'ownership-eye.svg'), path.join(outputRoot, 'assets', 'ownership-eye.svg')); fs.copyFileSync(path.join(process.cwd(), 'saturn-source', 'ownership-card.css'), path.join(outputRoot, 'assets', 'ownership-card.css')); fs.copyFileSync(path.join(process.cwd(), 'saturn-source', 'ownership-graphic.css'), path.join(outputRoot, 'assets', 'ownership-graphic.css')); fs.copyFileSync(path.join(sourceRoot, 'Структура собственности', "7610052644 ПАО 'ОДК-Сатурн'.svg"), path.join(outputRoot, 'assets', 'structure-source.svg')); fs.chmodSync(path.join(outputRoot, 'assets', 'structure-source.svg'), 0o644); fs.mkdirSync(path.join(outputRoot, 'assets', 'figma-ownership'), {recursive: true}); fs.copyFileSync(path.join(process.cwd(), 'saturn-source', 'figma-ownership', 'saturn-ownership.svg'), path.join(outputRoot, 'assets', 'figma-ownership', 'saturn-ownership.svg')); for (const source of targetSources) { const output = path.join(outputRoot, relativeOut(source)); fs.mkdirSync(path.dirname(output), {recursive: true}); fs.writeFileSync(output, page(source)); } const graphicShareholders = [ {key: 'odk', percent: '86,634%', name: 'АО «Объединённая двигателестроительная корпорация»', href: formatHref(mainSource, sourceIn(path.join('Структура собственности', 'АО _ОДК_.html')))}, {key: 'rostec', percent: '8,420%', name: 'Государственная корпорация «Ростех»', href: formatHref(mainSource, sourceIn(path.join('Структура собственности', 'Государственная корпорация _Ростех_.html')))}, {key: 'vniief', percent: '4,048%', name: 'ФГУП «РФЯЦ - ВНИИЭФ»', href: formatHref(mainSource, sourceIn(path.join('Структура собственности', 'ФГУП _РФЯЦ - ВНИИЭФ_.html')))}, {key: 'self', percent: '0,594%', name: 'ПАО «ОДК-Сатурн» (собственные акции)', href: 'index.html'}, {key: 'minority', percent: '0,899%', name: 'Неизвестные и/или миноритарные акционеры'} ]; const graphicShareholderCards = graphicShareholders.map(item => { const inner = `${item.percent}${item.name}`; return item.href ? `${inner}` : `
${inner}
`; }).join(''); const ownershipGraphicPage = ` Структура собственности ПАО «ОДК-Сатурн» — Бастион

ПАО «ОДК-Сатурн»

Структура собственности

Вернуться к организации
Акционеры (% ОА)

Пять долей — пять акционеров

Размер маркера и указанная доля соответствуют данным в карточке организации.

${graphicShareholderCards}
Организация ПАО «ОДК-Сатурн» ИНН 7610052644
`; fs.writeFileSync(path.join(outputRoot, 'structure-ownership.html'), ownershipGraphicPage); const generatedPages = walk(outputRoot).filter(file => file.endsWith('.html')); const root = fs.readFileSync(path.join(outputRoot, 'index.html'), 'utf8'); const required = ['Реквизиты организации и контактная информация', 'Правовой статус и виды деятельности', 'Информация', 'Финансовые показатели', 'Структура собственности']; const missing = required.filter(title => !root.includes(title)); const brokenLinks = []; for (const pageFile of generatedPages) { const html = fs.readFileSync(pageFile, 'utf8'); for (const match of html.matchAll(/href="([^"]+\.html)"/g)) { const target = path.resolve(path.dirname(pageFile), decodeURIComponent(match[1])); if (!fs.existsSync(target)) brokenLinks.push(`${path.relative(outputRoot, pageFile)} → ${match[1]}`); } } fs.writeFileSync(path.join(outputRoot, 'ЧЕКАП.md'), `# Чек-ап карточек ПАО «ОДК-Сатурн»\n\n- Исходных HTML-карточек: ${targetSources.length}; создано HTML-карточек: ${generatedPages.length}.\n- Каждая карточка сформирована из соответствующего исходного HTML; три файла «Доп.информация» включены в основную карточку как раздел «Информация».\n- На основной карточке проверены обязательные разделы: ${missing.length ? `не найдены: ${missing.join(', ')}` : 'все присутствуют'}.\n- Внутренние ссылки: ${brokenLinks.length ? `обнаружены неразрешённые ссылки (${brokenLinks.join('; ')})` : 'все разрешаются в существующие HTML-файлы'}.\n- В главной карточке финансовые показатели приведены к 2026 году; прогнозные значения и численность сотрудников помечены как расчётные данные для прототипа.\n- Исходная SVG-схема собственности сохранена в \`assets/structure-source.svg\`.\n- В доступных исходных HTML нет сведений о процедурах банкротства, субсидиях по их предупреждению или предупреждениях о банкротстве; отдельный пустой раздел не выводится.\n`); fs.writeFileSync(path.join(outputRoot, 'README.md'), `# ПАО «ОДК-Сатурн» — готовый прототип\n\nОткройте [index.html](index.html). Кнопка «Структура собственности в графическом виде» открывает отдельную адаптивную вкладку и сохраняет исходную карточку организации открытой. Все связанные карточки открываются в соседней вкладке.\n\nРабочая блок-схема собрана на HTML/CSS и использует общий фон приложения. Исходная SVG-схема сохранена в \`assets/structure-source.svg\`, прежняя Figma-версия — в \`assets/figma-ownership/saturn-ownership.svg\`.\n\nСм. [ЧЕКАП.md](ЧЕКАП.md) для результата проверки.\n`); console.log(`Created ${generatedPages.length} Saturn cards in ${outputRoot}`);