]*>([\s\S]*?)<\/a>/gi, '$1')
.replace(/<(?!\/?p\b|a\b)[^>]*>/gi, ' ')
.replace(/ | /gi, ' ')
.replace(/\s+/g, ' ')
.replace(/\s*<\/p>/g, '')
.replace(/(https?:\/\/[^\s<]+)/g, '$1 ')
.trim();
return `
${prepared}
`.replace(/\s*<\/p>/g, '').replace(/<\/p>\s*
/g, '
');
}
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 `
${rows.map(row => `${escapeHtml(row.label || 'Сведения')} ${row.html || '—'} `).join('')} `;
}
function card(title, content, id, open = true, modalContent = content) {
const textTitle = String(title).replace(/<[^>]*>/g, '');
return `
${title} ${open ? `Подробнее ` : ''}${content}${open ? `
${title} Закрыть × ${modalContent} ` : ''} `;
}
function compactList(rows, limit = 3) {
return `${rowList(rows.slice(0, limit))}
`;
}
// 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 = `Классификатор Код ${classifiers.map(row => `${escapeHtml(row.label)} ${row.html} `).join('')}
`;
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('Руководители', `${leaders.map(row => `
${row.html || '—'}
`).join('')}
`, '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('Корпоративные сведения', `${rowList(corporateRows)}
`, '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])=>anchoranchor>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
? `Динамика выручки и чистой прибыли ${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')} млн ₽`}
]))}
`
: rows.length ? `Показатели Росстата ${rowList(rows)}
` : '';
return card(`Финансовые показатели${isMain ? ` ${currentYear} ` : ''}`, `
${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 = '') => `
Акционеры (% ОА)
Номинал акции 1 000 руб.
`;
const related = (idSuffix = '') => `
`;
const graphicButton = `
Структура собственности в графическом виде
`;
const detailContent = `Держатель реестра акционеров: АО «ВТБ Регистратор»
${shareholders('-detail')}${related('-detail')}`;
return `
Структура собственности
${graphicButton}Развернуть ↗
${shareholders('')}
Структура собственности ${graphicButton}Закрыть ×
${detailContent}
`;
}
function aboutBlock(source) {
if (source !== mainSource) return '';
const summary = bodyText(extraSources.summary);
const history = bodyText(extraSources.history);
const full = `Основные направления деятельности (специализация) ${summary}
Историческая справка ${history}
`;
const preview = `1947 ОКБ МЭИ ведёт историю с 25 апреля 1947 года: в Московском энергетическом институте был создан Сектор специальных работ для решения задач ракетной техники.
В 1958 году он был преобразован в Особое конструкторское бюро МЭИ. Бюро участвовало в создании систем телеметрии и траекторных измерений для первых отечественных ракет и космических аппаратов.
`;
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 = `${latest} сотрудников
Оценочная численность на конец ${currentYear} года
`;
const full = content;
return card(`Численность сотрудников ${currentYear} `, 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 `${name.split(' ').map(part => part[0]).join('').slice(0, 2)}
${escapeHtml(name)} ${wiki ? `Википедия ` : ''}${card('Должности', `${positions.length ? positions.map(row => `
${row.html}
`).join('') : '
Сведения о должностях представлены в исходной карточке.
'}
`, 'positions')} `;
}
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 && `
Идентификаторы ${compactList(groups.ids)}`,
groups.location.length && `
Местоположение ${compactList(groups.location)}`,
groups.contacts.length && `
Контактная информация ${compactList(groups.contacts, 5)}`
].filter(Boolean).join('');
const detailModalContent = [
groups.ids.length && `Идентификаторы ${rowList(groups.ids)}
`,
groups.location.length && `Местонахождение ${rowList(groups.location)}
`,
groups.contacts.length && `Контактная информация ${rowList(groups.contacts)}
`
].filter(Boolean).join('');
const classifiers = classifiersBlock(rows);
const management = managementBlock(rows);
const corporate = corporateBlock(rows);
const body = person ? personIntro(title, rows, source) : `Реквизиты ${classifiers ? 'Классификаторы ' : ''}${groups.legal.length ? 'Статус ' : ''}${management ? 'Руководители ' : ''}${corporate ? 'Корпоративные сведения ' : ''}${source === mainSource ? 'Информация Собственность ' : ''}Сотрудники Финансы ${detailContent ? card('Реквизиты и контакты', `${detailContent}
`, 'details', true, `${detailModalContent}
`) : ''}${classifiers}${groups.legal.length ? card('Правовой статус и деятельность', `${groups.legal.map(row => `
${escapeHtml(row.label)}. ${row.html || '—'}
`).join('')}
`, 'legal') : ''}${management}${corporate}${aboutBlock(source)}${staffBlock(source)}${financeBlock(groups.finance, title, source)}${ownershipBlock(source)} `;
return `${escapeHtml(title)} — Бастион Поиск ⌕ АО «ОКБ МЭИ» ${escapeHtml(title)} ◉ ${body}
`;
}
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 = `
Структура собственности АО «ОКБ МЭИ» — Бастион
`;
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}`);