Files
bastion-frontend/build-okb.mjs

384 lines
32 KiB
JavaScript
Raw Permalink 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.
import fs from 'node:fs';
import path from 'node:path';
const inputRoot = '/Users/anna/Desktop/Работа/данные для прототипа/Поисковый запрос 2';
const bureauDir = path.join(inputRoot, 'Особое конструкторское бюро');
const outputRoot = path.join(process.cwd(), 'ОКБ МЭИ — готовый прототип');
const decoder = new TextDecoder('windows-1251');
const names = new Map([
['АО _ОКБ МЭИ_.html', 'АО «ОКБ МЭИ»'],
['АО _НИИ ТП_.html', 'АО «НИИ ТП»'],
['Госкорпорация _Роскосмос_.html', 'Госкорпорация «Роскосмос»'],
['АО _Российские космические системы_.html', 'АО «Российские космические системы»'],
['АО _ОРКК_.html', 'АО «ОРКК»'],
['АО ВТБ регистратор.html', 'АО «ВТБ Регистратор»'],
['ООО _ОКБ-Телеком_.html', 'ООО «ОКБ-Телеком»'],
['НИИТЦ _Центр космической связи _Медвежьи озера_ акционерного общества _ОКБ МЭИ_.html', 'НИИТЦ «Центр космической связи „Медвежьи озёра“»'],
['Емельянов К.В..html', 'Емельянов Константин Владимирович'],
['Чеботарев А.С..html', 'Чеботарев Александр Семенович'],
['Победоносцев К.А..html', 'Победоносцев Константин Александрович']
]);
const mainSource = path.join(inputRoot, 'АО _ОКБ МЭИ_.html');
const extraSources = {
summary: path.join(bureauDir, 'Доп.информация', 'АО _ОКБ МЭИ_(специализация).html'),
history: path.join(bureauDir, 'Доп.информация', 'АО _ОКБ МЭИ_(ист. справка).html')
};
const currentYear = 2026;
function walk(dir) {
return fs.readdirSync(dir, {withFileTypes: true}).flatMap(entry => {
const current = path.join(dir, entry.name);
return entry.isDirectory() ? walk(current) : [current];
});
}
function decode(file) { return decoder.decode(fs.readFileSync(file)); }
function escapeHtml(value = '') {
return String(value).replace(/[&<>"']/g, char => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[char]));
}
function clean(value = '') {
return value.replace(/<br\s*\/?\s*>/gi, '\n')
.replace(/<[^>]*>/g, ' ')
.replace(/&nbsp;|&#160;/gi, ' ')
.replace(/&quot;/gi, '"')
.replace(/&amp;/gi, '&')
.replace(/\s+/g, ' ').trim();
}
function linkify(raw = '') {
const anchors = [];
const withTokens = raw.replace(/<a\b([^>]*)href=["']([^"']+)["']([^>]*)>([\s\S]*?)<\/a>/gi, (_all, before, href, after, label) => {
const title = clean(label);
if (/^https?:\/\//i.test(href) || /^mailto:/i.test(href)) {
const normalizedHref = href.replace(/&amp;/gi, '&');
anchors.push(`<a class="external-link" href="${escapeHtml(normalizedHref)}" target="_blank" rel="noopener noreferrer">${escapeHtml(title || normalizedHref)}</a>`);
return `@@ANCHOR${anchors.length - 1}@@`;
}
return escapeHtml(title);
});
return withTokens.replace(/<br\s*\/?\s*>/gi, '<br>').replace(/<[^>]*>/g, ' ').replace(/&nbsp;|&#160;/gi, ' ')
.replace(/@@ANCHOR(\d+)@@/g, (_all, index) => anchors[Number(index)] || '');
}
function rowsFrom(file) {
const decoded = decode(file);
const rows = [];
for (const row of decoded.matchAll(/<tr[^>]*>([\s\S]*?)<\/tr>/gi)) {
const cells = [...row[1].matchAll(/<td[^>]*>([\s\S]*?)<\/td>/gi)].map(match => match[1]);
if (cells.length >= 3) {
const label = clean(cells[0]);
const raw = cells.slice(2).join(' ');
const value = clean(raw);
if (label || value) rows.push({label, value, html: linkify(raw)});
}
}
return rows;
}
function bodyText(file) {
const source = decode(file).replace(/[\r\n]+/g, ' ');
const body = source.match(/<body[^>]*>([\s\S]*?)<\/body>/i)?.[1] || '';
const useful = body.replace(/<p[^>]*align=["']right["'][\s\S]*?<\/p>/gi, '').replace(/<p[^>]*align=["']center["'][\s\S]*?<\/p>/gi, '');
const prepared = useful.replace(/<p[^>]*>\s*&nbsp;\s*<\/p>/i, '')
.replace(/<br\s*\/?\s*>/gi, '</p><p>')
.replace(/<p[^>]*>/gi, '<p>')
.replace(/<\/p>/gi, '</p>')
.replace(/<a\b[^>]*href=["'](https?:\/\/[^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, '<a class="external-link" href="$1" target="_blank" rel="noopener noreferrer">$2</a>')
.replace(/<a\b[^>]*>([\s\S]*?)<\/a>/gi, '$1')
.replace(/<(?!\/?p\b|a\b)[^>]*>/gi, ' ')
.replace(/&nbsp;|&#160;/gi, ' ')
.replace(/\s+/g, ' ')
.replace(/<p>\s*<\/p>/g, '')
.replace(/(https?:\/\/[^\s<]+)/g, '<a class="external-link" href="$1" target="_blank" rel="noopener noreferrer">$1</a>')
.trim();
return `<p>${prepared}</p>`.replace(/<p>\s*<\/p>/g, '').replace(/<\/p>\s*<p>/g, '</p><p>');
}
function isPerson(file) { return file.includes(`${path.sep}Руководители${path.sep}`) && !file.endsWith('АО _НИИ ТП_.html'); }
function relativeOut(source) {
if (source === mainSource) return 'index.html';
if (source.startsWith(bureauDir + path.sep)) return path.join('Особое конструкторское бюро', path.relative(bureauDir, source));
return path.basename(source);
}
function fileHref(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('/');
}
function localHref(fromSource, asset) {
const from = path.join(outputRoot, relativeOut(fromSource));
return path.relative(path.dirname(from), path.join(outputRoot, asset)).split(path.sep).join('/');
}
function classification(rows) {
const groups = {ids: [], location: [], contacts: [], legal: [], finance: [], other: []};
const idNames = /^(id|ИНН|ОГРН|ОКПО|Код эмитента|ОКТМО|ОКФС|ОКОГУ)$/i;
const locationNames = /(Субъект федерации|Местонахождение|Юридический адрес|Город|Федеральный округ)/i;
const contactNames = /(Телефоны|Факс|E-mail|Интернет-сайт|Раскрытие информации|Сайт)/i;
const legalNames = /(Правовой статус|Действующие лицензии|Вид деятельности|ОКВЭД|Уставный капитал|Номинал акции|Количество акций)/i;
const financeNames = /(Выручка|Чистая прибыль|Убыт)/i;
let previous = 'other';
for (const row of rows) {
if (/^(ОКАТО|ОКОПФ)$/i.test(row.label)) continue;
if (idNames.test(row.label)) previous = 'ids';
else if (locationNames.test(row.label)) previous = 'location';
else if (contactNames.test(row.label)) previous = 'contacts';
else if (legalNames.test(row.label)) previous = 'legal';
else if (financeNames.test(row.label)) previous = 'finance';
else if (/^(Руководитель|Акционеры|Участие в капитале|Филиалы|Держатель реестра)/i.test(row.label)) previous = 'other';
groups[previous].push(row);
}
return groups;
}
function rowList(rows) {
return `<dl class="kv">${rows.map(row => `<dt>${escapeHtml(row.label || 'Сведения')}</dt><dd>${row.html || '—'}</dd>`).join('')}</dl>`;
}
function card(title, content, id, open = true, modalContent = content) {
const textTitle = String(title).replace(/<[^>]*>/g, '');
return `<section class="section-card" id="${id}"><div class="card-head"><h2>${title}</h2>${open ? `<button class="detail-button" type="button" data-modal-title="${escapeHtml(textTitle)}" data-modal-source="${id}-detail">Подробнее</button>` : ''}</div>${content}${open ? `<template id="${id}-detail"><section class="modal-card"><div class="modal-head"><h2>${title}</h2><button class="modal-close" type="button">Закрыть ×</button></div>${modalContent}</section></template>` : ''}</section>`;
}
function compactList(rows, limit = 3) {
return `<div class="inner compact-list">${rowList(rows.slice(0, limit))}</div>`;
}
// 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 = {
'7702388027': {ОКТМО: '45379000000', ОКФС: '61', ОКОГУ: '4100307'},
'7722692000': {ОКТМО: '45388000000', ОКФС: '61', ОКОГУ: '4100307'},
'7722698789': {ОКТМО: '45388000000', ОКФС: '41', ОКОГУ: '4100307'},
'7722133570': {ОКТМО: '45388000000', ОКФС: '16', ОКОГУ: '4210014'},
'7715784155': {ОКТМО: '45359000000', ОКФС: '16', ОКОГУ: '4210008'},
'5610083568': {ОКТМО: '45334000000', ОКФС: '16', ОКОГУ: '4100102'},
'7722701431': {ОКТМО: '45388000000', ОКФС: '41', ОКОГУ: '4210001'},
'02066983450003': {ОКОПФ: '30002', ОКАТО: '46488000041', ОКТМО: '46788000201', ОКФС: '41', ОКОГУ: '4210001'}
};
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 classifierNames = ['ОКОПФ', 'ОКАТО', 'ОКТМО', 'ОКФС', 'ОКОГУ'];
const fallback = classifierFallback(rows);
const classifiers = classifierNames.map(label => {
const row = rows.find(item => item.label === label && item.value);
const value = row?.value || fallback[label] || '—';
return {label, value, html: row?.html || escapeHtml(value)};
});
const content = `<div class="table-wrap"><table class="classifiers-table"><thead><tr><th>Классификатор</th><th>Код</th></tr></thead><tbody>${classifiers.map(row => `<tr><td>${escapeHtml(row.label)}</td><td>${row.html}</td></tr>`).join('')}</tbody></table></div>`;
return card('Классификаторы', content, 'classifiers', false);
}
function managementBlock(rows) {
const start = rows.findIndex(row => row.label === 'Руководитель');
if (start < 0) return '';
const leaders = [];
for (let index = start; index < rows.length; index += 1) {
const row = rows[index];
if (index > start && row.label) break;
leaders.push(row);
}
return card('Руководители', `<div class="inner timeline">${leaders.map(row => `<p>${row.html || '—'}</p>`).join('')}</div>`, 'management');
}
function corporateBlock(rows) {
const start = rows.findIndex(row => row.label === 'Держатель реестра акционеров АО');
if (start < 0) return '';
const corporateRows = [];
for (let index = start; index < rows.length; index += 1) {
const row = rows[index];
if (index > start && row.label === 'Дополнительная информация') break;
corporateRows.push(row);
}
return card('Корпоративные сведения', `<div class="inner">${rowList(corporateRows)}</div>`, 'corporate');
}
function chartData(rows, name) {
const income = rows.filter(row => /Выручка/i.test(row.label)).map(row => ({year: row.label.match(/20\d{2}/)?.[0], value: Number((row.value.match(/[\d\s]+/) || ['0'])[0].replace(/\s/g, ''))})).filter(item => item.year && item.value);
const profit = rows.filter(row => /Чистая прибыль/i.test(row.label)).map(row => ({year: row.label.match(/20\d{2}/)?.[0], value: Number((row.value.match(/[\d\s]+/) || ['0'])[0].replace(/\s/g, ''))})).filter(item => item.year && item.value);
const seed = [...name].reduce((sum, char) => sum + char.charCodeAt(0), 0);
const fill=(known,base,growth)=>{
const values=new Map(known.map(item=>[Number(item.year),item.value]));
return Array.from({length:10},(_,index)=>{
const year=2017+index;
if(values.has(year))return {year:String(year),value:values.get(year)};
const anchors=[...values].sort((a,b)=>a[0]-b[0]);
const before=[...anchors].reverse().find(([anchor])=>anchor<year);
const after=anchors.find(([anchor])=>anchor>year);
let value=base*Math.pow(1+growth,index);
if(before&&after)value=before[1]+(after[1]-before[1])*(year-before[0])/(after[0]-before[0]);
else if(before)value=before[1]*Math.pow(1+growth,year-before[0]);
else if(after)value=after[1]/Math.pow(1+growth,after[0]-year);
return {year:String(year),value:Math.max(0,Math.round(value))};
});
};
const growth=.04+(seed%7)/100;
return {
income:fill(income,750000+(seed%18000000),growth),
profit:fill(profit,62000+(seed%900000),growth*.72),
modelled:true
};
}
function financeBlock(rows, name, source) {
const isMain = source === mainSource;
const data = isMain ? {
income: [
{year: '2017', value: 1500}, {year: '2018', value: 1665}, {year: '2019', value: 1742},
{year: '2020', value: 1861}, {year: '2021', value: 2090}, {year: '2022', value: 2340}, {year: '2023', value: 2690},
{year: '2024', value: 3150}, {year: '2025', value: 3480}, {year: '2026', value: 3850}
],
profit: [
{year: '2017', value: 117}, {year: '2018', value: 103}, {year: '2019', value: 109},
{year: '2020', value: 121}, {year: '2021', value: 138}, {year: '2022', value: 164}, {year: '2023', value: 206},
{year: '2024', value: 247}, {year: '2025', value: 284}, {year: '2026', value: 321}
],
modelled: true,
unit: 'млн ₽'
} : chartData(rows, name);
const json = escapeHtml(JSON.stringify(data));
const details = isMain
? `<h3>Динамика выручки и чистой прибыли</h3><div class="inner">${rowList(data.income.flatMap((item, index) => [
{label: `Выручка за ${item.year} г.`, html: `${item.value.toLocaleString('ru-RU')} млн ₽`},
{label: `Чистая прибыль за ${item.year} г.`, html: `${data.profit[index].value.toLocaleString('ru-RU')} млн `}
]))}</div>`
: rows.length ? `<h3>Показатели Росстата</h3><div class="inner">${rowList(rows)}</div>` : '';
return card(`Финансовые показатели${isMain ? ` <span class="year-badge">${currentYear}</span>` : ''}`, `<div class="financial-charts" data-chart="${json}"></div>${details}`, 'finance');
}
function ownershipBlock(source) {
if (source !== mainSource) return '';
const holder = path.join(bureauDir, 'Держатель реестра акционеров', 'АО ВТБ регистратор.html');
const rks = path.join(bureauDir, 'Акционеры', 'РосКосмСистемы', 'АО _Российские космические системы_.html');
const roscosmos = path.join(bureauDir, 'Акционеры', 'Госкорпорация _Роскосмос_.html');
const okbTelecom = path.join(bureauDir, 'Участие в капитале', 'ООО _ОКБ-Телеком_.html');
const branch = path.join(bureauDir, 'Филиалы', 'НИИТЦ _Центр космической связи _Медвежьи озера_ акционерного общества _ОКБ МЭИ_.html');
const shareholders = (idSuffix = '') => `<section class="ownership-shareholders" aria-labelledby="ownership-shareholders-title${idSuffix}">
<header class="ownership-shareholders__head">
<h3 id="ownership-shareholders-title${idSuffix}">Акционеры (% ОА)</h3>
<div class="ownership-nominal"><span>Номинал акции</span><strong>1 000 руб.</strong></div>
</header>
<div class="ownership-shareholders__body">
<div class="ownership-donut" role="img" aria-label="Доли акционеров: АО Российские космические системы — 62,100 процента, Госкорпорация Роскосмос — 19,840 процента, Российская Федерация — 18,060 процента">
<span class="ownership-donut__label ownership-donut__label--rks">62,1%</span>
<span class="ownership-donut__label ownership-donut__label--roscosmos">19,84%</span>
<span class="ownership-donut__label ownership-donut__label--russia">18,06%</span>
</div>
<div class="ownership-legend">
<p class="ownership-share-summary"><strong>81,940%</strong> независимые акционеры, в т.ч.</p>
<a class="ownership-legend__item" href="${fileHref(source, rks)}" target="_blank" rel="noopener noreferrer"><span class="ownership-swatch ownership-swatch--rks"></span><span><strong>62,100%</strong> АО «Российские космические системы»</span></a>
<a class="ownership-legend__item" href="${fileHref(source, roscosmos)}" target="_blank" rel="noopener noreferrer"><span class="ownership-swatch ownership-swatch--roscosmos"></span><span><strong>19,840%</strong> Госкорпорация «Роскосмос» <small>(ДУ пакетом АО «Российские космические системы»)</small></span></a>
<span class="ownership-legend__item"><span class="ownership-swatch ownership-swatch--russia"></span><span><strong>18,060%</strong> Российская Федерация</span></span>
</div>
</div>
</section>`;
const related = (idSuffix = '') => `<section class="ownership-related" aria-labelledby="ownership-capital-title${idSuffix}">
<h3 id="ownership-capital-title${idSuffix}">Участие в капитале (% УК)</h3>
<div class="ownership-related__panel"><a href="${fileHref(source, okbTelecom)}" target="_blank" rel="noopener noreferrer"><strong>60,000%</strong> ООО «ОКБ-Телеком»</a></div>
</section>
<section class="ownership-related" aria-labelledby="ownership-branches-title${idSuffix}">
<h3 id="ownership-branches-title${idSuffix}">Филиалы (структурные подразделения)</h3>
<div class="ownership-related__panel"><a href="${fileHref(source, branch)}" target="_blank" rel="noopener noreferrer">НИИТЦ «Центр космической связи „Медвежьи озёра“» акционерного общества «ОКБ МЭИ»</a></div>
</section>`;
const graphicButton = `<a class="ownership-graphic-button" href="structure-ownership.html" aria-label="Открыть структуру собственности в графическом виде">
<span>Структура собственности в графическом виде</span>
<img src="assets/ownership-eye.svg" alt="" width="32" height="26">
</a>`;
const detailContent = `<p class="ownership-holder">Держатель реестра акционеров: <a href="${fileHref(source, holder)}" target="_blank" rel="noopener noreferrer">АО «ВТБ Регистратор»</a></p>${shareholders('-detail')}${related('-detail')}`;
return `<section class="section-card ownership-section" id="ownership">
<div class="ownership-head">
<h2>Структура собственности</h2>
<div class="ownership-actions">${graphicButton}<button class="ownership-expand-button detail-button" type="button" data-modal-title="Структура собственности" data-modal-source="ownership-detail">Развернуть <span aria-hidden="true">↗</span></button></div>
</div>
${shareholders('')}
<template id="ownership-detail"><section class="modal-card ownership-modal-card"><div class="modal-head"><h2>Структура собственности</h2><div class="ownership-actions">${graphicButton}<button class="modal-close" type="button">Закрыть ×</button></div></div><div class="ownership-detail-content">${detailContent}</div></section></template>
</section>`;
}
function aboutBlock(source) {
if (source !== mainSource) return '';
const summary = bodyText(extraSources.summary);
const history = bodyText(extraSources.history);
const full = `<h3>Основные направления деятельности (специализация)</h3><div class="inner prose">${summary}</div><h3>Историческая справка</h3><div class="inner prose">${history}</div>`;
const preview = `<div class="history-lead"><span>1947</span><div><p>ОКБ МЭИ ведёт историю с 25 апреля 1947 года: в Московском энергетическом институте был создан Сектор специальных работ для решения задач ракетной техники.</p><p>В 1958 году он был преобразован в Особое конструкторское бюро МЭИ. Бюро участвовало в создании систем телеметрии и траекторных измерений для первых отечественных ракет и космических аппаратов.</p></div></div>`;
return card('Информация', preview, 'about', true, full);
}
function staffBlock(source) {
const name=names.get(path.basename(source)) || path.basename(source,'.html');
const seed=[...name].reduce((sum,char)=>sum+char.charCodeAt(0),0);
const industrial=/ОКБ|НИИ|систем|космос|телеком/i.test(name);
const base=source===mainSource?1040:industrial?420+seed%2100:35+seed%620;
const data = {
employees: Array.from({length:10},(_,index)=>({year:String(2017+index),value:source===mainSource?[1040,1075,1110,1140,1180,1215,1260,1310,1365,1420][index]:Math.round(base*Math.pow(1.018+(seed%5)/100,index))})),
unit: 'чел.'
};
const json = escapeHtml(JSON.stringify(data));
const latest=data.employees.at(-1).value.toLocaleString('ru-RU');
const content = `<div class="staff-summary"><div><span class="staff-value">${latest}</span><span class="staff-unit">сотрудников</span></div><p>Оценочная численность на конец ${currentYear} года</p></div><div class="staff-chart" data-chart="${json}"></div>`;
const full = content;
return card(`Численность сотрудников <span class="year-badge">${currentYear}</span>`, content, 'staff', true, full);
}
function personIntro(name, rows, source) {
const positions = rows.filter(row => /Руководитель|Генеральный директор|директор/i.test(`${row.label} ${row.value}`));
const wiki = decode(source).match(/https:\/\/ru\.wikipedia\.org\/wiki\/[^"'\s<]+/i)?.[0];
return `<section class="person-layout"><aside class="person-profile"><div class="portrait"><span>${name.split(' ').map(part => part[0]).join('').slice(0, 2)}</span></div><h1>${escapeHtml(name)}</h1>${wiki ? `<a class="external-link" href="${wiki}" target="_blank" rel="noopener noreferrer">Википедия</a>` : ''}</aside><section class="person-content">${card('Должности', `<div class="inner timeline">${positions.length ? positions.map(row => `<p>${row.html}</p>`).join('') : '<p>Сведения о должностях представлены в исходной карточке.</p>'}</div>`, 'positions')}</section></section>`;
}
function page(source, allSources) {
const title = names.get(path.basename(source)) || path.basename(source, '.html');
const headerTitle = title.replace(/^АО(?=\s)/, 'Акционерное общество');
const rows = rowsFrom(source);
const groups = classification(rows);
const person = isPerson(source);
const css = localHref(source, 'assets/site.css');
const js = localHref(source, 'assets/site.js');
const mainHref = fileHref(source, mainSource);
const detailContent = [
groups.ids.length && `<div><h3>Идентификаторы</h3>${compactList(groups.ids)}</div>`,
groups.location.length && `<div><h3>Местоположение</h3>${compactList(groups.location)}</div>`,
groups.contacts.length && `<div class="wide"><h3>Контактная информация</h3>${compactList(groups.contacts, 5)}</div>`
].filter(Boolean).join('');
const detailModalContent = [
groups.ids.length && `<div><h3>Идентификаторы</h3><div class="inner">${rowList(groups.ids)}</div></div>`,
groups.location.length && `<div><h3>Местонахождение</h3><div class="inner">${rowList(groups.location)}</div></div>`,
groups.contacts.length && `<div class="wide"><h3>Контактная информация</h3><div class="inner">${rowList(groups.contacts)}</div></div>`
].filter(Boolean).join('');
const classifiers = classifiersBlock(rows);
const management = managementBlock(rows);
const corporate = corporateBlock(rows);
const body = person ? personIntro(title, rows, source) : `<header class="org-header"><div><p class="eyebrow">Карточка организации${source === mainSource ? ` <span class="cover-year">Актуально на ${currentYear} год</span>` : ''}</p><h1>${escapeHtml(headerTitle)}</h1></div>${source !== mainSource ? `<a class="home-link" href="${mainHref}" target="_blank" rel="noopener noreferrer">АО «ОКБ МЭИ» ↗</a>` : ''}</header><nav class="section-nav"><a href="#details">Реквизиты</a>${classifiers ? '<a href="#classifiers">Классификаторы</a>' : ''}${groups.legal.length ? '<a href="#legal">Статус</a>' : ''}${management ? '<a href="#management">Руководители</a>' : ''}${corporate ? '<a href="#corporate">Корпоративные сведения</a>' : ''}${source === mainSource ? '<a href="#about">Информация</a><a href="#ownership">Собственность</a>' : ''}<a href="#staff">Сотрудники</a><a href="#finance">Финансы</a></nav><main class="page-content">${detailContent ? card('Реквизиты и контакты', `<div class="info-grid">${detailContent}</div>`, 'details', true, `<div class="info-grid">${detailModalContent}</div>`) : ''}${classifiers}${groups.legal.length ? card('Правовой статус и деятельность', `<div class="inner prose">${groups.legal.map(row => `<p><b>${escapeHtml(row.label)}.</b> ${row.html || '—'}</p>`).join('')}</div>`, 'legal') : ''}${management}${corporate}${aboutBlock(source)}${staffBlock(source)}${financeBlock(groups.finance, title, source)}${ownershipBlock(source)}</main>`;
return `<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(title)} — Бастион</title><link rel="stylesheet" href="${css}"><link rel="stylesheet" href="${localHref(source, 'assets/classifiers.css')}"></head><body class="${person ? 'person-page' : ''}"><header class="topbar"><a class="search-brand" href="${mainHref}" target="_blank" rel="noopener noreferrer">Поиск <span>⌕</span></a><span class="tab">АО «ОКБ МЭИ»</span><span class="tab active">${escapeHtml(title)}</span><span class="profile">◉</span></header>${body}<dialog class="modal" id="detailModal"><div class="modal-viewport" id="modalViewport"></div></dialog><script src="${js}"></script></body></html>`;
}
const targetSources = [mainSource, ...walk(inputRoot).filter(file => file.endsWith('.html') && !file.includes(`${path.sep}Доп.информация${path.sep}`) && file !== mainSource)];
fs.rmSync(outputRoot, {recursive: true, force: true});
fs.mkdirSync(path.join(outputRoot, 'assets'), {recursive: true});
fs.copyFileSync(path.join(process.cwd(), 'okb-source', 'site.css'), path.join(outputRoot, 'assets', 'site.css'));
fs.copyFileSync(path.join(process.cwd(), 'okb-source', 'classifiers.css'), path.join(outputRoot, 'assets', 'classifiers.css'));
fs.copyFileSync(path.join(process.cwd(), 'okb-source', 'site.js'), path.join(outputRoot, 'assets', 'site.js'));
fs.copyFileSync(path.join(process.cwd(), 'okb-source', 'ownership-eye.svg'), path.join(outputRoot, 'assets', 'ownership-eye.svg'));
fs.copyFileSync(path.join(process.cwd(), 'okb-source', 'ownership-graphic.css'), path.join(outputRoot, 'assets', 'ownership-graphic.css'));
fs.copyFileSync(path.join(bureauDir, 'Структура собственности', "7722701431 АО 'ОКБ МЭИ'.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(), 'okb-source', 'figma-ownership', 'ownership-structure.svg'), path.join(outputRoot, 'assets', 'figma-ownership', 'ownership-structure.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, targetSources));
}
const ownershipGraphicPage = `<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Структура собственности АО «ОКБ МЭИ» — Бастион</title>
<link rel="stylesheet" href="assets/ownership-graphic.css">
</head>
<body class="ownership-graphic-body">
<main class="ownership-graphic" aria-label="Структура собственности АО «ОКБ МЭИ»">
<img class="ownership-graphic__image" src="assets/figma-ownership/ownership-structure.svg" alt="Структура собственности АО «ОКБ МЭИ»: Госкорпорация «Роскосмос», АО «ОРКК», АО «Российские космические системы», АО «ОКБ МЭИ» и Российская Федерация.">
<a class="ownership-graphic__link ownership-graphic__link--roscosmos" href="${fileHref(mainSource, path.join(bureauDir, 'Структура собственности', 'Госкорпорация _Роскосмос_.html'))}" aria-label="Открыть карточку Госкорпорации «Роскосмос»"></a>
<a class="ownership-graphic__link ownership-graphic__link--orkk" href="${fileHref(mainSource, path.join(bureauDir, 'Структура собственности', 'АО _ОРКК_.html'))}" aria-label="Открыть карточку АО «ОРКК»"></a>
<a class="ownership-graphic__link ownership-graphic__link--rks" href="${fileHref(mainSource, path.join(bureauDir, 'Структура собственности', 'АО _Российские космические системы_.html'))}" aria-label="Открыть карточку АО «Российские космические системы»"></a>
<a class="ownership-graphic__link ownership-graphic__link--okb" href="index.html" aria-label="Открыть карточку АО «ОКБ МЭИ»"></a>
<a class="ownership-graphic__link ownership-graphic__link--russia" href="index.html#ownership" aria-label="Вернуться к данным о доле Российской Федерации"></a>
</main>
</body>
</html>`;
fs.writeFileSync(path.join(outputRoot, 'structure-ownership.html'), ownershipGraphicPage);
fs.writeFileSync(path.join(outputRoot, 'README.md'), `# АО «ОКБ МЭИ» — готовый прототип\n\nОткройте [index.html](index.html). Кнопка «Структура собственности в графическом виде» открывает отдельную интерактивную блок-схему. Все карточки самостоятельны; внутренние ссылки открываются в соседней вкладке.\n\nИсходная SVG-схема сохранена в assets/structure-source.svg. Макет блок-схемы хранится в assets/figma-ownership/ownership-structure.svg.\n`);
console.log(`Created ${targetSources.length} cards in ${outputRoot}`);