555 lines
31 KiB
TypeScript
555 lines
31 KiB
TypeScript
/**
|
||
* 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 BULGAKOV_PATH = join(ROOT, 'scripts', 'output', 'bulgakov.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_MINEI = 'Четьи-Минеи свт. Димитрия Ростовского';
|
||
const COPYRIGHT_BULGAKOV = 'Протоиерей Сергий Булгаков. Настольная Книга.';
|
||
|
||
interface SourceLife {
|
||
title: string;
|
||
html: string;
|
||
}
|
||
|
||
interface Candidate {
|
||
cid: string;
|
||
name: string;
|
||
file: string;
|
||
hasLife: boolean;
|
||
score: number;
|
||
}
|
||
|
||
interface ReportEntry {
|
||
date: string;
|
||
sourceTitle: string;
|
||
source: 'minei' | 'bulgakov' | 'unknown';
|
||
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',
|
||
// Bulgakov fixes (feast-related entries)
|
||
'02-22|обретение честных мощей': '491',
|
||
'11-20|во храм входа пресвятой': '257510',
|
||
'11-25|отда́нїе праздника входа': '257512',
|
||
'12-20|по плоти рождества господа бога и спаса': '09001',
|
||
'12-22|из церковных песнопений этого дня': '09001',
|
||
'12-23|из церковных песнопений этого дня': '09001',
|
||
'12-24|из церковных песнопений этого дня': '09006',
|
||
'12-26|собо́р прест҃ы́ѧ бг҃оро́дицы': '12260098',
|
||
'12-31|отда́нїе праздника рождества̀ христо́ва': '123101',
|
||
};
|
||
|
||
/** 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[]>;
|
||
|
||
// Optional second source: Bulgakov
|
||
let bulgakov: Record<string, SourceLife[]> = {};
|
||
if (existsSync(BULGAKOV_PATH)) {
|
||
bulgakov = JSON.parse(readFileSync(BULGAKOV_PATH, 'utf-8'));
|
||
console.log(`Loaded Bulgakov source: ${Object.keys(bulgakov).length} days, ${Object.values(bulgakov).flat().length} entries`);
|
||
}
|
||
|
||
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;
|
||
const filledByMinei = new Set<string>();
|
||
|
||
// Process a single source (minei or bulgakov) for a given date
|
||
function processSource(
|
||
date: string,
|
||
entries: SourceLife[],
|
||
source: 'minei' | 'bulgakov',
|
||
copyright: string,
|
||
idPrefix: string,
|
||
candidates: Candidate[],
|
||
skipCids: Set<string>,
|
||
): void {
|
||
for (const life of entries) {
|
||
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, source, matchedCid: null, matchedName: null, score: 0, status: 'unmatched' });
|
||
unmatched++;
|
||
continue;
|
||
}
|
||
|
||
// For Bulgakov: skip if this CId was already filled by Minei
|
||
if (source === 'bulgakov' && skipCids.has(best.cid)) {
|
||
report.push({ date, sourceTitle: life.title, source, matchedCid: best.cid, matchedName: best.name, score: best.score, status: 'already-filled' });
|
||
continue;
|
||
}
|
||
|
||
const status = best.hasLife
|
||
? 'already-filled'
|
||
: best.score >= 0.6
|
||
? 'auto'
|
||
: best.score >= 0.35
|
||
? 'review'
|
||
: 'unmatched';
|
||
|
||
report.push({
|
||
date,
|
||
sourceTitle: life.title,
|
||
source,
|
||
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, text: `${existingLife.text}<p><b>${life.title}</b></p>${life.html}` };
|
||
} else {
|
||
entry.life = { id: `${idPrefix}-${date.replace('-', '')}-${best.cid}`, copyright, text: life.html };
|
||
}
|
||
if (source === 'minei') skipCids.add(best.cid);
|
||
} else if (status === 'review') {
|
||
needReview++;
|
||
} else if (status === 'unmatched') {
|
||
unmatched++;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Build a set of all dates from both sources
|
||
const allDates = new Set(Object.keys(minei));
|
||
if (Object.keys(bulgakov).length > 0) {
|
||
for (const d of Object.keys(bulgakov)) allDates.add(d);
|
||
}
|
||
|
||
for (const date of [...allDates].sort()) {
|
||
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);
|
||
|
||
// Process Minei entries first (primary source)
|
||
const mineiLives = minei[date] || [];
|
||
processSource(date, mineiLives, 'minei', COPYRIGHT_MINEI, 'minei', candidates, filledByMinei);
|
||
|
||
// Process Bulgakov entries second (fills gaps only)
|
||
if (Object.keys(bulgakov).length > 0) {
|
||
const bulgakovLives = bulgakov[date] || [];
|
||
if (bulgakovLives.length > 0) {
|
||
processSource(date, bulgakovLives, 'bulgakov', COPYRIGHT_BULGAKOV, 'bulgakov', candidates, filledByMinei);
|
||
}
|
||
}
|
||
}
|
||
|
||
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);
|
||
})();
|
||
}
|