Files
ponomar-pwa/scripts/enrich-lives.ts
T

496 lines
29 KiB
TypeScript
Raw 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.
/**
* Enrich ru/lives bundles with Chetyi-Minei texts.
*
* Pipeline:
* 1. Load scripts/output/minei.json (source lives by MM-DD, from convert-minei.ts).
* 2. Load static/data/cu/menaion-bundle.json (authoritative date -> CId mapping).
* 3. Load ru/lives bundles (names per CId; target for enrichment).
* 4. For each date, fuzzy-match source life titles to candidate CIds' names.
* 5. Fill life.text (only entries that currently lack it) + write report.
*
* Usage:
* npx tsx scripts/enrich-lives.ts # dry run -> report only
* npx tsx scripts/enrich-lives.ts --write # apply changes
* npx tsx scripts/enrich-lives.ts --month 01 # limit to one month (report)
* npx tsx scripts/enrich-lives.ts --write --month 01
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'fs';
import { join, dirname } from 'path';
const ROOT = join(new URL('.', import.meta.url).pathname, '..');
const MINEI_PATH = join(ROOT, 'scripts', 'output', 'minei.json');
const MENAION_BUNDLE_PATH = join(ROOT, 'static', 'data', 'cu', 'menaion-bundle.json');
const LIVES_DIR = join(ROOT, 'static', 'data', 'ru', 'lives');
const REPORT_PATH = join(ROOT, 'scripts', 'output', 'lives-match-report.json');
const COPYRIGHT = 'Четьи-Минеи свт. Димитрия Ростовского';
interface SourceLife {
title: string;
html: string;
}
interface Candidate {
cid: string;
name: string;
file: string;
hasLife: boolean;
score: number;
}
interface ReportEntry {
date: string;
sourceTitle: string;
matchedCid: string | null;
matchedName: string | null;
score: number;
status: 'auto' | 'review' | 'unmatched' | 'already-filled' | 'no-name';
}
const ROLE_WORDS = new Set([
'святого', 'святой', 'святая', 'святых', 'святому', 'святою', 'святыя',
'свт', 'святитель', 'святителя', 'святителю',
'преподобного', 'преподобный', 'преподобной', 'преподобных', 'прп', 'прпп',
'мученика', 'мученик', 'мучеников', 'мученицы', 'мучениц', 'мученице',
'мч', 'мчч', 'мц', 'мцц', 'сщмч', 'сщмчч', 'прпмч', 'прпмц', 'прмч',
'блаженного', 'блаженный', 'блаженной', 'блаж',
'праведного', 'праведный', 'праведной', 'прав',
'пророка', 'пророк', 'пророков',
'апостола', 'апостол', 'апостолов', 'апостолы', 'ап',
'священномученика', 'священномучеников',
'отца', 'отец', 'отцов', 'нашего', 'наших', 'наш', 'его', 'ея', 'ей', 'их',
'житие', 'жития', 'страдание', 'страданиев', 'страдания', 'память', 'слово',
'богоносного', 'вселенского', 'собора', 'собор',
'иже', 'во', 'в', 'на', 'по', 'за', 'от', 'о', 'об', 'к', 'и', 'с',
'вмч', 'вмц', 'великомученика', 'великомученик',
'еп', 'епископа', 'епископ', 'архиеп', 'архиепископа', 'архиепископ',
'митр', 'митрополита', 'патриарха', 'патриарх', 'игумена', 'игумении',
'пресвитера', 'пресвитер', 'диакона', 'диакон', 'князя', 'князь', 'кн',
'царя', 'царь', 'императора', 'император', 'девы', 'дева', 'девица',
'сестры', 'сестер', 'брата', 'братий', 'чад', 'чада', 'сына', 'сын',
'воинов', 'отрока', 'отрок', 'младенцев', 'авраам', 'аврамия',
'ок', 'иже', 'св', 'блгв', 'блж', 'прмц', 'прав', 'прор',
]);
/**
* Manual overrides for cases the fuzzy matcher cannot resolve
* (near-ties, token-subset collisions, name homonyms on the same date).
* Key: `${date}|${distinctive lowercase substring of the source title}`.
*/
const MANUAL_FIXES: Record<string, string> = {
'01-02|серафима саровского': '010202',
'01-04|семидесяти апостолов': '146',
'01-06|слово святого иоанна златоустого на богоявление': '163',
'01-07|десной руке': '1007',
'01-08|илии пустынника': '175',
'01-08|иулиана и василиссы': '174',
'01-09|петра, епископа севастийского': '010905',
'01-12|петра авесаламита': '202',
'01-14|преподобного стефана': '219',
'01-26|иосифа солунского': '31701',
'01-30|ипполита, кенсорина': '339',
'01-30|преподобного зинона': '341',
'02-09|никифора': '1621',
'02-11|память святого благоверного князя всеволода, нареченного во святом крещении гавриилом': '438',
'02-11|память святой царицы феодоры': '431',
'02-14|память преподобного марона': '1485',
'02-14|память святого авраама, епископа каррийского': '448',
'02-16|память святого маруфа епископа и святых мучеников, пострадавших в мартирополе': '021602',
'02-21|память преподобного отца нашего тимофея, пустынника в символах': '484',
'02-22|память преподобного афанасия исповедника': '302',
'02-28|память святого священномученика нестора, епископа магидийского': '527',
'02-29|память преподобного иоанна, нареченного варсонофия': '532',
'03-05|прозванию огородника': '570',
'03-06|страдание святого преподономученика конона и сына его конона, в иконии пострадавших': '582',
'03-07|павла исповедника': '589',
'03-09|память святого кесария': '601',
'03-10|кодрата и прочих': '604',
'03-15|память святых мучеников агапия, пуплия, тимолая, ромила, александра, александра, дионисия и дионисия': '646',
'03-17|житие преподобного отца нашего макария колязинского': '1404',
'03-18|житие преподобного отца нашего анина чудотворца': '66801',
'03-20|память святого никиты исповедника': '682',
'03-20|страдание святых мучениц александры и клавдии': '681',
'03-21|память преподобного отца нашего иакова, епископа и исповедника': '685',
'03-23|память святых мучеников филита и лидии': '696',
'03-23|священномученика никона': '694',
'03-24|память преподобного отца нашего иакова исповедника': '68501',
'03-29|страдание преподобного отца нашего марка, епископа арефусийского': '729',
'03-31|память преподобного аполлония': '746',
'04-04|зосимы': '754',
'04-04|страдание святой мученицы фервуфы': '780',
'04-05|память преподобного отца нашего платона исповедника': '791',
'04-12|житие преподобной матери нашей афанасии': '838',
'04-13|страдание святого мученика крискента': '850',
'04-14|страдание святых мучеников 1000 и азата': '84',
'04-16|святой мученицы ирины': '2367',
'04-21|мученика феодора': '909',
'05-04|страдание святого мученика сильвана, епископа кесийского [1] и с ним сорока мучеников': '1990',
'05-06|память святого варвара, бывшего прежде разбойником': '1003',
'05-06|страдание святого мученика варвара воина и других с ним': '1002',
'05-10|страдание святых мучеников алфия, филадельфа, киприана [1] и других, с ними пострадавших': '1031',
'05-12|савина': '1045',
'05-14|житие преподобного отца нашего серапиона синдонита': '1064',
'05-17|память святого мученика солохона': '1089',
'05-20|страдание святого мученика фалалея': '110801',
'05-23|михаила исповедника': '1130',
'05-24|страдание святого мученика серапиона и с ним каллиника, феодора и фавста': '1135',
'05-27|перенесение честных мощей святителей киприана, фотия и ионы, киевских и всея россии чудотворцев': '1209',
'05-27|ферапонта, белозерского': '2275',
'05-29|память святой мученицы феодосии': '271401',
'05-29|преподобномученицы феодосии': '1169',
'06-04|житие преподобного отца нашего зосимы, епископа вавилонского': '1228',
'06-05|память преподобного отца нашего феодора пустынника': '1239',
'06-05|повесть о кончине блаженного константина, митрополита киевского': '06050200',
'06-05|преставление преподобного отца нашего анувия исповедника и пустынножителя египетского': '123802',
'06-06|страдание святых преподобномучениц архелаи, феклы и сусанны': '1249',
'06-07|маркелла, папы': '1253',
'06-08|перенесение мощей святого великомученика феодора стратилата': '220101',
'06-10|житие святого отца нашего вассиана, епископа лодийского': '1278',
'06-13|житие святого отца нашего трифиллия, епископа левкусийского': '1296',
'06-20|житие во святых отца наш его левкия исповедника': '1355',
'07-02|память святого отца нашего ювеналия, патриарха иерусалимского': '4140',
'07-02|сказание о положении честной ризы пречистой девы богородицы во влахернской церкви': '4138',
'07-06|страдание святой мученицы лукии девицы и с нею рикса и других': '4169',
'07-09|память преподобного коприя': '4201',
'07-09|память преподобного патермуфия': '4201',
'07-10|память преподобного отца нашего антония печерского': '21801',
'07-11|воспоминание о чуде святой великомученицы евфимии всехвальной': '9479',
'07-11|преставление блаженной княгини ольги, во святом крещении елены': '2634',
'07-12|страдание святой мученицы голиндухи': '4224',
'07-14|память преподобного еллия': '4238',
'07-25|житие святой олимпиады диакониссы': '4301',
'07-27|память преподобной игумении анфисы': '4316',
'07-28|память святого мученика акакия': '4325',
'08-02|перенесение мощей святого первомученика и архидиакона стефана': '2051',
'08-07|преставление преподобного ора черноризца': '4393',
'08-14|о создании церкви честного успения пресвятой богородицы в киево-печерской обители': '444411',
'08-22|тот же день': '4494',
'08-26|сретения владимирской иконы': '281201',
'08-27|пимена (палестинского)': '4533',
'08-30|житие святого благоверного князя александра невского': '114',
'08-30|память преподобного отца нашего фантина': '4551',
'09-03|житие и страдание святого священномученика анфима, епископа никомидийского, и с ним многих': '1439',
'09-04|память святой мученицы ермионии': '1449',
'09-04|священномученика вавилы': '1446',
'09-05|память святых мучеников урвана, феодора и медимна, и многих с ними': '1458',
'09-05|убиение святого князя глеба': '51801',
'09-06|страдание святого мученика евдоксия': '1462',
'09-09|воспоминание святого третьего вселенского собора в ефесе': '1495',
'09-09|память преподобного иосифа волоколамского': '1055',
'09-09|память преподобного феофана, постника и исповедника': '1493',
'09-10|память святых апостолов апеллия, луки и климента': '1501',
'09-13|память преподобного петра в атрое': '152502',
'09-15|память святого филофея, пресвитера и чудотворца': '1534',
'09-19|память святого благоверного князя феодора, смоленского и ярославского чудотворца, и чад его давида и константина': '219701',
'09-20|житие и страдание святого великомученика евстафия плакиды, его супруги и чад': '156901',
'09-20|страдание святых мучеников михаила, князя черниговского и боярина его феодора, от нечестивого батыя пострадавших': '155001',
'09-21|житие святителя димитрия, митрополита ростовского, чудотворца': '60102',
'09-21|память святых епископов исакия и мелетия': '09210100',
'09-22|вертоградар': '1582',
'09-22|житие святого петра, прежде бывшего мытарем': '1583',
'09-22|ионы пресвитера': '1581',
'09-22|синопского': '232101',
'09-23|житие преподобной поликсении': '1588',
'09-24|никандра псковского': '1595',
'09-25|житие преподобной матери нашей евфросинии': '160601',
'09-27|житие и страдание святого мученика каллистрата': '1611',
'09-30|житие и страдание святого священномученика григория, епископа великой армении, и с ним тридцати семи дев': '1632',
'09-30|память святого михаила, митрополита киевского и всея россии чудотворца': '1553',
'10-04|память святого мученика давикта и дочери его каллисфении': '10040200',
'10-07|житие преподобного отца нашего сергия нуромского, чудотворца вологодского и обнорского': '1971',
'10-08|пелагии девы': '10080000',
'10-08|преподобной матери нашей пелагии': '2641',
'10-09|житие святого праведного авраама': '1697',
'10-09|память святой поплии': '1699',
'10-11|воспоминание седьмого вселенского собора': '9914',
'10-11|житие святого апостола филиппа': '2292',
'10-16|житие и страдание святого мученика лонгина сотника': '136801',
'10-17|память святого праведного лазаря четверодневного': '1760',
'10-22|память святого равноапостольного аверкия, епископа иерапольского': '4571',
'10-23|боровицкого': '868',
'10-26|страдание и чудеса святого славного великомученика димитрия': '60401',
'10-27|память святых мучениц капетолины и еротииды': '6661',
'10-28|память святых мучеников терентия и неониллы': '10280000',
'10-28|страдание святой великомученицы параскевы': '14172',
'11-05|житие и страдание святых преподобномучеников галактиона и епистимы': '248801',
'11-10|страдание священномученика милия': '2516',
'11-22|михаила воина': '2585',
'11-23|память святого мученика сисиния': '3214',
'11-23|память святого мученика феодора': '3214',
'11-25|житие и страдание святого священномученика петра, архиепископа александрийского': '6969',
'11-26|воспоминание освящения храма великомученика георгия в киеве': '49702',
'11-27|память святого палладия, епископа еленопольского': '11270000',
'11-28|память святого мученика иринарха': '3242',
'11-29|память святого мученика парамона': '3246',
'12-04|житие и страдание святой великомученицы варвары': '3061',
'12-07|память преподобного павла повинника': '3080',
'12-29|житие преподобных марка пещерника и феофила плачливого': '146402',
'12-29|память преподобного фаддея исповедника': '2156',
'12-30|память преподобного зотика сиротопитателя': '3199',
'12-30|цареградской': '3203',
'02-02|сказание о сретении': '373',
'02-04|епископа арвильскаго': '388',
'02-05|феодулии': '393',
'02-07|тысячи и трех': '411',
'02-15|онисима, одного из лика': '450',
'02-22|варадата': '493',
'02-22|фалассия': '493',
'03-06|42 мучеников': '57901',
'03-09|сорока мучеников, в армянской севастии': '1238',
'03-10|переименованной': '607',
'03-14|евcхимона': '642',
'03-19|хрисанфа и дарии': '673',
'03-20|фотины': '680',
'03-22|дросиды': '690',
'03-24|воспоминание о чуде': '822',
'03-24|артемона': '6673',
'03-26|26-ти мучеников готских': '712',
'03-28|стефана исповедника': '723',
'03-28|ионы и варахисия': '726',
'04-03|никиты исповедника': '767',
'04-29|кизике': '09951',
'05-04|алвиана': '992',
'05-10|taиcии': '2685',
'05-13|георгия исповедника': '106001',
'05-15|евфросина, псковского': '74701',
'05-24|мелетия стратилата': '1135',
'06-03|лукиана': '1220',
'06-07|маркеллина': '1252',
'06-26|лидской': '1399',
'07-08|казанской': '2865',
'07-25|евпраксии': '4301',
'07-30|полихрония': '4352',
'07-30|валентина': '336',
'08-05|фавия': '08051300',
'08-08|явления ее пречестной': '4404',
'08-09|иулиана, маркиана': '4410',
'08-11|сосанны': '4420',
'08-13|ипполита': '4432',
'08-16|херимона': '4449',
'08-20|севир и мемнон': '4484',
'08-25|в едессе от ариан': '4517',
'08-27|осии исповедника': '4531',
'08-28|святой праведной анны': '2396',
'09-01|индикта': '09426',
'09-02|3618 мучеников': '143601',
'09-03|аристиона': '09030000',
'09-03|василисы': '1440',
'09-05|захарии': '1453',
'09-09|тайного угодника': '149401',
'09-09|севириана': '1492',
'09-11|мужском образе': '1506',
'09-12|автонома': '1512',
'09-16|киприана, митрополита киевского': '120901',
'09-18|евмений': '1559',
'09-21|кодрат': '1256',
'09-23|ираиды': '2656',
'09-24|равноапостольной феклы': '1594',
'09-25|пафнутия': '1605',
'10-05|дамиана': '1674',
'10-05|иеремии': '1674',
'10-05|матфея': '1674',
'10-05|мамелхфы': '1678',
'10-05|московским': '1779',
'10-12|мальтийских': '1722',
'11-20|саверия': '257401',
'11-20|нирсы': '257401',
'11-28|стефаном новым': '3244',
};
/** True if a token is a year, year-range, or Roman numeral (e.g. 309, 1238, V, IX). */
function isYearish(tok: string): boolean {
if (/^\d+$/.test(tok)) return true;
if (/^[ivxlc]+$/.test(tok)) return true;
return false;
}
function normalizeToken(tok: string): string {
return tok.toLowerCase().replace(/ё/g, 'е').replace(/[^\p{L}]/gu, '');
}
function tokenize(s: string): string[] {
return s
.replace(/\([^)]*\)/g, ' ')
.replace(/[()[\].,;:—–-]/g, ' ')
.split(/\s+/)
.map(normalizeToken)
.filter(t => t.length > 1 && !ROLE_WORDS.has(t) && !isYearish(t));
}
/** Normalize a title for MANUAL_FIXES substring lookup. */
function normKey(s: string): string {
return s.toLowerCase().replace(/ё/g, 'е').replace(/\s+/g, ' ').trim();
}
/** Look up a manual override: date + distinctive normalized substring. */
function findManualFix(date: string, title: string): string | null {
const t = normKey(title);
for (const [key, cid] of Object.entries(MANUAL_FIXES)) {
const [d, sub] = key.split('|');
if (d === date && t.includes(sub)) return cid;
}
return null;
}
/** Dice coefficient over significant tokens. */
function scoreMatch(source: string, name: string): number {
const a = tokenize(source);
const b = tokenize(name);
if (a.length === 0 || b.length === 0) return 0;
const setA = new Set(a);
let inter = 0;
for (const t of b) if (setA.has(t)) inter++;
return (2 * inter) / (a.length + b.length);
}
function bundleFileForCid(cid: string): string {
return /^(0[1-9]|1[0-2])\d+/.test(cid) ? `${cid.substring(0, 2)}.json` : 'misc.json';
}
interface Args {
write: boolean;
month: string | null;
}
function parseArgs(argv: string[]): Args {
return {
write: argv.includes('--write'),
month: argv.includes('--month') ? argv[argv.indexOf('--month') + 1] ?? null : null,
};
}
export async function enrichLives(args: Args): Promise<void> {
if (!existsSync(MINEI_PATH)) {
console.error('Missing scripts/output/minei.json — run convert-minei.ts first.');
process.exit(1);
}
const minei = JSON.parse(readFileSync(MINEI_PATH, 'utf-8')) as Record<string, SourceLife[]>;
const bundle = JSON.parse(readFileSync(MENAION_BUNDLE_PATH, 'utf-8')) as Record<string, { id: string }[]>;
// Load all ru/lives bundles into one map: cid -> {name, life, file}
const livesByCid: Record<string, { name: Record<string, string>; life?: { text?: string }; file: string }> = {};
for (const f of readdirSync(LIVES_DIR).filter(f => f.endsWith('.json'))) {
const bundleData = JSON.parse(readFileSync(join(LIVES_DIR, f), 'utf-8')) as Record<string, { name?: Record<string, string>; life?: { text?: string } }>;
for (const [cid, entry] of Object.entries(bundleData)) {
livesByCid[cid] = { name: entry.name || {}, life: entry.life, file: f };
}
}
const report: ReportEntry[] = [];
const pendingWrites: Record<string, Record<string, unknown>> = {};
let autoApplied = 0;
let needReview = 0;
let unmatched = 0;
for (const [date, lives] of Object.entries(minei)) {
if (args.month && !date.startsWith(args.month)) continue;
// Candidate CIds for this date from the menaion bundle
const bundleIds = (bundle[date] || []).map(e => String(e.id));
const candidates: Candidate[] = bundleIds
.map(cid => {
const entry = livesByCid[cid];
if (!entry) return null;
const name = entry.name.nominative || entry.name.short || '';
return {
cid,
name,
file: entry.file,
hasLife: !!entry.life?.text,
score: 0,
} as Candidate;
})
.filter((c): c is Candidate => c !== null);
for (const life of lives) {
let best: Candidate | null = null;
const manualCid = findManualFix(date, life.title);
if (manualCid) {
const entry = livesByCid[manualCid];
if (entry) {
best = {
cid: manualCid,
name: entry.name.nominative || entry.name.short || '',
file: entry.file,
hasLife: !!entry.life?.text,
score: 1,
};
}
} else {
for (const cand of candidates) {
const score = scoreMatch(life.title, cand.name);
if (score > (best?.score ?? 0)) {
best = { ...cand, score };
}
}
}
if (!best || best.score === 0) {
report.push({ date, sourceTitle: life.title, matchedCid: null, matchedName: null, score: 0, status: 'unmatched' });
unmatched++;
continue;
}
const status = best.hasLife
? 'already-filled'
: best.score >= 0.6
? 'auto'
: best.score >= 0.35
? 'review'
: 'unmatched';
report.push({
date,
sourceTitle: life.title,
matchedCid: best.score >= 0.35 ? best.cid : null,
matchedName: best.score >= 0.35 ? best.name : null,
score: best.score,
status,
});
if (status === 'auto') {
autoApplied++;
const targetFile = best.file;
const key = targetFile.replace('.json', '');
if (!pendingWrites[key]) pendingWrites[key] = JSON.parse(readFileSync(join(LIVES_DIR, targetFile), 'utf-8'));
const entry = pendingWrites[key][best.cid] as Record<string, unknown>;
const existingLife = entry.life as { id?: string; text?: string } | undefined;
if (existingLife?.text) {
entry.life = { id: existingLife.id!, copyright: COPYRIGHT, text: `${existingLife.text}<p><b>${life.title}</b></p>${life.html}` };
} else {
entry.life = { id: `minei-${date.replace('-', '')}-${best.cid}`, copyright: COPYRIGHT, text: life.html };
}
} else if (status === 'review') {
needReview++;
} else if (status === 'unmatched') {
unmatched++;
}
}
}
mkdirSync(dirname(REPORT_PATH), { recursive: true });
writeFileSync(REPORT_PATH, JSON.stringify(report, null, 0));
console.log(`Auto-applied: ${autoApplied}, needs review: ${needReview}, unmatched: ${unmatched}`);
console.log(`Report: ${REPORT_PATH}`);
if (args.write) {
for (const [key, data] of Object.entries(pendingWrites)) {
writeFileSync(join(LIVES_DIR, `${key}.json`), JSON.stringify(data));
}
console.log(`Wrote ${Object.keys(pendingWrites).length} updated bundle(s).`);
} else {
const months = new Set(report.filter(r => r.status === 'auto').map(r => r.date.slice(0, 2)));
console.log(`Dry run — no files changed. Auto-applied covers months: ${[...months].sort().join(', ')}`);
console.log('Re-run with --write to apply.');
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
(async () => {
const args = parseArgs(process.argv.slice(2));
await enrichLives(args);
})();
}