Implement updated Bastion prototype and search workflow

This commit is contained in:
KrivovDE
2026-09-09 15:42:13 +03:00
parent 28f0852847
commit 4af653de9c
143 changed files with 14540 additions and 99 deletions

162
build-main-card-data.mjs Normal file
View File

@@ -0,0 +1,162 @@
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(/&lt;/gi, '<')
.replace(/&gt;/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.`);