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 => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[char])); } function clean(value = '') { return value.replace(//gi, '\n') .replace(/<[^>]*>/g, ' ') .replace(/ | /gi, ' ') .replace(/"/gi, '"') .replace(/&/gi, '&') .replace(/\s+/g, ' ').trim(); } function linkify(raw = '') { const anchors = []; const withTokens = raw.replace(/]*)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(/&/gi, '&'); anchors.push(`${escapeHtml(title || normalizedHref)}`); return `@@ANCHOR${anchors.length - 1}@@`; } return escapeHtml(title); }); return withTokens.replace(//gi, '
').replace(/<[^>]*>/g, ' ').replace(/ | /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(/]*>([\s\S]*?)<\/tr>/gi)) { const cells = [...row[1].matchAll(/]*>([\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(/]*>([\s\S]*?)<\/body>/i)?.[1] || ''; const useful = body.replace(/]*align=["']right["'][\s\S]*?<\/p>/gi, '').replace(/]*align=["']center["'][\s\S]*?<\/p>/gi, ''); const prepared = useful.replace(/]*>\s* \s*<\/p>/i, '') .replace(//gi, '

') .replace(/]*>/gi, '

') .replace(/<\/p>/gi, '

') .replace(/]*href=["'](https?:\/\/[^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, '$2') .replace(/]*>([\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 ? `` : ''}
`; } 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 => ``).join('')}
КлассификаторКод
${escapeHtml(row.label)}${row.html}
`; 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('')}
`; } 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 `
${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) : `

Карточка организации${source === mainSource ? ` Актуально на ${currentYear} год` : ''}

${escapeHtml(headerTitle)}

${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}`);