163 lines
8.1 KiB
JavaScript
163 lines
8.1 KiB
JavaScript
import crypto from 'node:crypto';
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
|
||
const workspace = process.cwd();
|
||
const decoder = new TextDecoder('windows-1251');
|
||
const sources = [
|
||
{
|
||
scope: 'okb',
|
||
root: '/Users/anna/Desktop/Работа/данные для прототипа/Поисковый запрос 2',
|
||
main: 'АО _ОКБ МЭИ_.html',
|
||
parentKey: 'okb-mei',
|
||
extra: {
|
||
history: 'Особое конструкторское бюро/Доп.информация/АО _ОКБ МЭИ_(ист. справка).html',
|
||
specialization: 'Особое конструкторское бюро/Доп.информация/АО _ОКБ МЭИ_(специализация).html'
|
||
}
|
||
},
|
||
{
|
||
scope: 'saturn',
|
||
root: '/Users/anna/Desktop/Работа/данные для прототипа/Поисковый запрос 3',
|
||
main: 'ПАО _ОДК-Сатурн_.html',
|
||
parentKey: 'saturn',
|
||
extra: {
|
||
about: 'Сатурн/Доп.информация/ПАО _ОДК-Сатурн_(справка).html',
|
||
history: 'Сатурн/Доп.информация/ПАО _ОДК-Сатурн_(ист. справка).html',
|
||
specialization: 'Сатурн/Доп.информация/ПАО _ОДК-Сатурн_(специализация).html'
|
||
}
|
||
}
|
||
];
|
||
|
||
const walk = directory => fs.readdirSync(directory, {withFileTypes: true}).flatMap(entry => {
|
||
const target = path.join(directory, entry.name);
|
||
return entry.isDirectory() ? walk(target) : [target];
|
||
});
|
||
const decode = file => decoder.decode(fs.readFileSync(file));
|
||
const decodeEntities = value => String(value || '')
|
||
.replace(/ | /gi, ' ')
|
||
.replace(/"|"/gi, '"')
|
||
.replace(/&/gi, '&')
|
||
.replace(/</gi, '<')
|
||
.replace(/>/gi, '>');
|
||
const clean = value => decodeEntities(String(value || ''))
|
||
.replace(/<br\s*\/?\s*>/gi, ' ')
|
||
.replace(/<[^>]*>/g, ' ')
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
const quoteName = value => clean(value).replace(/"([^"\n]+)"/g, '«$1»');
|
||
const sourceTitle = file => quoteName(decode(file).match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] || path.basename(file, '.html'));
|
||
const stableKey = (scope, relative) => `source-${scope}-${crypto.createHash('sha1').update(relative).digest('hex').slice(0, 10)}`;
|
||
|
||
function rowsFrom(file) {
|
||
const rows = [];
|
||
for (const row of decode(file).matchAll(/<tr[^>]*>([\s\S]*?)<\/tr>/gi)) {
|
||
const cells = [...row[1].matchAll(/<td[^>]*>([\s\S]*?)<\/td>/gi)].map(match => match[1]);
|
||
if (cells.length < 3) continue;
|
||
const label = clean(cells[0]);
|
||
const raw = cells.slice(2).join(' ');
|
||
const value = quoteName(raw);
|
||
if (label || value) rows.push({label, value, raw});
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
function bodyText(file) {
|
||
let body = decode(file).replace(/[\r\n]+/g, ' ').match(/<body[^>]*>([\s\S]*?)<\/body>/i)?.[1] || '';
|
||
body = body.replace(/<p[^>]*align=["'](?:right|center)["'][\s\S]*?<\/p>/gi, ' ');
|
||
return clean(body);
|
||
}
|
||
|
||
function groupedRows(rows) {
|
||
const groups = Object.fromEntries(['ids', 'location', 'contacts', 'legal', 'finance', 'staff', 'management', 'ownership', 'structure', 'stability', 'other'].map(key => [key, []]));
|
||
let current = 'other';
|
||
const bucket = label => {
|
||
if (/^(id|ИНН|КПП|ОГРН|ОКПО|Код эмитента|ОКОПФ|ОКАТО|ОКТМО|ОКФС|ОКОГУ)$/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 (row.label) current = bucket(row.label);
|
||
groups[current].push(row);
|
||
}
|
||
return groups;
|
||
}
|
||
|
||
const rowsText = rows => rows.map(row => `${row.label ? `${row.label}: ` : ''}${row.value}`).filter(Boolean).join('\n');
|
||
const firstValue = (rows, pattern) => rows.find(row => pattern.test(row.label))?.value || '';
|
||
const cleanPairs = rows => rows.filter(row => row.label && row.value).map(row => [row.label === 'id' ? 'ID' : row.label, row.value]);
|
||
|
||
function cardData(file, scope, relative) {
|
||
const rows = rowsFrom(file);
|
||
const groups = groupedRows(rows);
|
||
const title = sourceTitle(file);
|
||
const person = /^[А-ЯЁ][А-ЯЁа-яё-]+\s+[А-ЯЁ]\.[А-ЯЁ]\.$/.test(title) || /\/Руководители\//.test(`/${relative}`) && !/^(АО|ПАО|ООО|ОАО|ФГУП)\s/.test(title);
|
||
if (person) {
|
||
return {
|
||
title,
|
||
short: title,
|
||
type: 'person',
|
||
pairs: cleanPairs(groups.ids),
|
||
positions: groups.management.map(row => row.value).filter(Boolean),
|
||
sourcePath: relative
|
||
};
|
||
}
|
||
const pairs = cleanPairs([...groups.ids, ...groups.location, ...groups.contacts]);
|
||
const activityKind = firstValue(groups.legal, /^Вид деятельности$/i);
|
||
const okved = firstValue(groups.legal, /^ОКВЭД$/i);
|
||
const legalRows = groups.legal.filter(row => !/^(Вид деятельности|ОКВЭД)$/i.test(row.label));
|
||
const ownership = rowsText([...groups.ownership, ...groups.structure]);
|
||
return {
|
||
title,
|
||
short: title,
|
||
subtitle: relative.split('/').slice(0, -1).join(' · '),
|
||
pairs,
|
||
leadershipText: groups.management.map(row => row.value).filter(Boolean),
|
||
legal: rowsText(legalRows),
|
||
legalDetails: legalRows.map(row => `${row.label ? `${row.label}: ` : ''}${row.value}`),
|
||
activity: [activityKind, okved].filter(Boolean).join('. '),
|
||
ownership,
|
||
stability: groups.stability.length ? {bankruptcy: rowsText(groups.stability)} : undefined,
|
||
sourcePath: relative
|
||
};
|
||
}
|
||
|
||
const cards = {};
|
||
const titleIndex = {};
|
||
const mainExtras = {};
|
||
|
||
for (const config of sources) {
|
||
const htmlFiles = walk(config.root)
|
||
.filter(file => file.endsWith('.html'))
|
||
.filter(file => !file.includes(`${path.sep}Доп.информация${path.sep}`))
|
||
.sort((left, right) => left.localeCompare(right, 'ru'));
|
||
const mainFile = path.join(config.root, config.main);
|
||
const imported = htmlFiles.filter(file => file !== mainFile);
|
||
const keyByFile = new Map(imported.map(file => {
|
||
const relative = path.relative(config.root, file).split(path.sep).join('/');
|
||
return [file, stableKey(config.scope, relative)];
|
||
}));
|
||
for (const file of imported) {
|
||
const relative = path.relative(config.root, file).split(path.sep).join('/');
|
||
const key = keyByFile.get(file);
|
||
const data = cardData(file, config.scope, relative);
|
||
cards[key] = data;
|
||
(titleIndex[data.title] ||= []).push(key);
|
||
}
|
||
for (const [field, relative] of Object.entries(config.extra || {})) {
|
||
const file = path.join(config.root, relative);
|
||
if (fs.existsSync(file)) (mainExtras[config.parentKey] ||= {})[field] = bodyText(file);
|
||
}
|
||
}
|
||
|
||
const output = `/* Сгенерировано build-main-card-data.mjs из исходных HTML. */\nwindow.BASTION_IMPORTED_CARDS = ${JSON.stringify({cards, titleIndex, mainExtras}, null, 2)};\n`;
|
||
fs.writeFileSync(path.join(workspace, 'assets/js/main-card-data.js'), output);
|
||
console.log(`Generated ${Object.keys(cards).length} linked cards.`);
|