Files
ponomar-pwa/scripts/convert-bulgakov.ts
T

273 lines
9.9 KiB
TypeScript

/**
* Convert Bulgakov's "Настольная книга для священно-церковнослужителей"
* EPUB to date-keyed JSON for enrichment of ru/lives bundles.
*
* Only processes the Menaion section (months 01-12).
* Keeps original pre-revolutionary orthography.
* Resolves footnotes inline.
* Excludes hymn texts (Troparia/Kontakia).
*
* Usage:
* npx tsx scripts/convert-bulgakov.ts <epub-path> [output.json]
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { execSync } from 'child_process';
const MONTH_NAMES: Record<string, string> = {
'январь': '01', 'января': '01',
'февраль': '02', 'февраля': '02',
'март': '03', 'марта': '03',
'апрель': '04', 'апреля': '04',
'май': '05', 'мая': '05',
'июнь': '06', 'июня': '06',
'июль': '07', 'июля': '07',
'август': '08', 'августа': '08',
'сентябрь': '09', 'сентября': '09',
'октябрь': '10', 'октября': '10',
'ноябрь': '11', 'ноября': '11',
'декабрь': '12', 'декабря': '12',
};
interface BulgakovEntry {
title: string;
html: string;
}
interface BulgakovDay {
[dateKey: string]: BulgakovEntry[];
}
/** Strip HTML tags, collapse whitespace. */
function stripTags(s: string): string {
return s.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
}
/** Clean a paragraph's HTML, resolving footnotes inline. */
function cleanParagraph(raw: string, notes: string[]): string {
let s = raw.trim();
// Resolve footnotes: <a href="..." title="NOTE TEXT"...><sup>[N]</sup></a>
s = s.replace(
/<a\s[^>]*title="([^"]*)"[^>]*>(<sup[^>]*>\[(\d+)\]<\/sup>)<\/a>/g,
(_m: string, note: string, sup: string) => {
notes.push(note.trim());
return sup;
}
);
// Remove remaining <a> tags (keep inner content)
s = s.replace(/<a\s[^>]*>(.*?)<\/a>/gs, (_m: string, inner: string) => inner);
// Strip calibre-specific classes
s = s.replace(/<(\w+)\s[^>]*class="[^"]*calibre[^"]*"[^>]*>/g, '<$1>');
s = s.replace(/<span\s[^>]*class="ponomar"[^>]*>(.*?)<\/span>/g, '$1');
s = s.replace(/<br\s[^>]*class="calibre\d+"\s*\/?>/g, '<br>');
s = s.replace(/\u00a0/g, ' ').replace(/\s+/g, ' ');
return `<p>${s}</p>`;
}
/** Check if a paragraph is a hymn (Troparion/Kontakion) or liturgical rubric. */
function isHymn(text: string): boolean {
const clean = stripTags(text).replace(/^\d+\s*\.?\s*/, '').trim();
const hymnPatterns = [
/^Тропарь,/i, /^Кондак,/i, /^Парем\./i, /^Утр\.\s*Ев\./i,
/^Ап\./i, /^Ев\./i, /^Совершается собор/i, /^В этот день поется/i,
/^Совершается/i, /^Служба/i, /^См\.\s/i,
];
return hymnPatterns.some(p => p.test(clean));
}
/** Check if a paragraph is a cross-reference (starts with "См." or "см."). */
function isCrossRef(text: string): boolean {
const clean = stripTags(text).replace(/^\d+\s*\.?\s*/, '').trim();
return /^см\.\s/i.test(clean);
}
/** Extract the day number from the start of a paragraph. */
function extractDayNum(text: string): number | null {
const m = text.match(/<strong[^>]*>(\d+)<\/strong>\.?\s*/i);
return m ? parseInt(m[1], 10) : null;
}
/** Extract the title from a paragraph (first significant text after day number and ponomar span). */
function extractTitle(text: string): string {
let s = text;
// Remove any leading anchor tags
s = s.replace(/^<a\s[^>]*><\/a>\s*/i, '');
// Remove day number prefix
s = s.replace(/^<strong[^>]*>\d+<\/strong>\.?\s*/i, '');
// Handle ponomar span: may contain a month znamenanie (single letter) or saint name
const ponomarMatch = s.match(/^<[^>]*>([^<]+)<\/[^>]+>\s*/);
if (ponomarMatch) {
let ponomarContent = ponomarMatch[1].trim();
// If ponomar is just a single letter (with optional period), it's a znamenanie — skip entire span
if (/^\p{L}\.?\s*$/u.test(ponomarContent)) {
s = s.replace(/^<[^>]*>[^<]+<\/[^>]+>\s*/, '');
} else {
// Ponomar may contain znamenanie + title (e.g. "Л. 🕀 Срѣтенїе...")
// Check if it starts with a single letter + period
const znamenanieMatch = ponomarContent.match(/^(\p{L}\.)\s*(.*)$/su);
if (znamenanieMatch) {
// Replace the span with just the title part (remove znamenanie)
s = s.replace(/^<[^>]*>[^<]+<\/[^>]+>\s*/, znamenanieMatch[2] + ' ');
}
}
}
// Strip all HTML tags
s = stripTags(s);
// Remove leading non-letter symbols (like †, 🕀, 🕂, 🕃)
s = s.replace(/^[\s\p{P}\p{S}]+/u, '');
// Take first 100 chars, breaking at a period where possible
const MAX_TITLE = 100;
if (s.length <= MAX_TITLE) return s;
// Try to break at a period within the first 100 chars
const periodIdx = s.lastIndexOf('.', MAX_TITLE);
if (periodIdx > 10) return s.substring(0, periodIdx + 1);
return s.substring(0, MAX_TITLE) + '...';
}
/** Check if text contains a month name (for heading detection). */
function findMonthName(text: string): string | null {
const lower = text.toLowerCase();
for (const [name, _code] of Object.entries(MONTH_NAMES)) {
if (lower.includes(name)) return name;
}
return null;
}
export async function convertBulgakov(epubPath: string, outputPath: string): Promise<BulgakovDay> {
const tmpDir = join(dirname(outputPath), 'tmp_bulgakov');
if (existsSync(tmpDir)) {
execSync(`rm -rf "${tmpDir}"`);
}
mkdirSync(tmpDir, { recursive: true });
console.log(`Extracting EPUB: ${epubPath}`);
execSync(`unzip -o "${epubPath}" -d "${tmpDir}" > /dev/null 2>&1`);
const out: BulgakovDay = {};
let currentMonth: string | null = null;
let currentDay: number | null = null;
let inMenaion = false;
// Parse all files in order
for (let i = 0; ; i++) {
const fileNum = String(i).padStart(3, '0');
const filePath = join(tmpDir, `index_split_${fileNum}.xhtml`);
if (!existsSync(filePath)) break;
if (i % 10 === 0) console.log(` Processing file ${fileNum}...`);
const html = readFileSync(filePath, 'utf-8');
// Check for h2 headings containing month names
// h2 looks like: <h2 ...><a...></a><a...></a>Январь <br.../></h2>
const h2Regex = /<h2[^>]*>([\s\S]*?)<\/h2>/gi;
let h2Match: RegExpExecArray | null;
while ((h2Match = h2Regex.exec(html)) !== null) {
const h2Content = stripTags(h2Match[1]);
const monthName = findMonthName(h2Content);
if (monthName) {
currentMonth = MONTH_NAMES[monthName];
currentDay = null;
inMenaion = true;
console.log(` → Month ${currentMonth}: ${monthName} (file ${fileNum})`);
}
}
// Check for section headers (Месяцеслов / Триодион)
if (/<h1[^>]*>[\s\S]*?Месяцеслов[\s\S]*?<\/h1>/i.test(html)) {
inMenaion = true;
console.log(` → Entered Месяцеслов section (file ${fileNum})`);
}
if (/<h1[^>]*>[\s\S]*?Триодион[\s\S]*?<\/h1>/i.test(html)) {
inMenaion = false;
console.log(` → Left Месяцеслов, entered Триодион (file ${fileNum})`);
}
if (!inMenaion || !currentMonth) continue;
// Extract all paragraph divs
const paraRegex = /<div class="paragraph">([\s\S]*?)<\/div>/g;
let paraMatch: RegExpExecArray | null;
// We process paragraphs in order. Each paragraph may be:
// - A new day entry (strong with day number)
// - Continuation of current day
// - A hymn (skipped)
while ((paraMatch = paraRegex.exec(html)) !== null) {
const raw = paraMatch[1];
// Check for day number
const dayNum = extractDayNum(raw);
if (dayNum !== null) {
currentDay = dayNum;
// Skip hymns and cross-refs
if (isHymn(raw) || isCrossRef(raw)) {
continue;
}
// New entry for this day
const title = extractTitle(raw);
const notes: string[] = [];
const cleanHtml = cleanParagraph(raw, notes);
const dateKey = `${currentMonth}-${String(dayNum).padStart(2, '0')}`;
if (!out[dateKey]) out[dateKey] = [];
const entry: BulgakovEntry = { title, html: cleanHtml };
if (notes.length > 0) {
entry.html += `<p>Примечания:</p>${notes.map((n, i) => `<p>[${i + 1}] ${n}</p>`).join('')}`;
}
out[dateKey].push(entry);
} else if (currentDay !== null) {
// Skip hymn continuations
if (isHymn(raw) || isCrossRef(raw)) continue;
// Continuation paragraph for current day - append to last entry
const dateKey = `${currentMonth}-${String(currentDay).padStart(2, '0')}`;
const lives = out[dateKey];
if (lives && lives.length > 0) {
const last = lives[lives.length - 1];
const notes: string[] = [];
const cleanHtml = cleanParagraph(raw, notes);
last.html += cleanHtml;
if (notes.length > 0) {
const noteBlock = `<p>Примечания:</p>${notes.map((n, i) => `<p>[${i + 1}] ${n}</p>`).join('')}`;
last.html += noteBlock;
}
}
}
}
}
// Cleanup temp directory
execSync(`rm -rf "${tmpDir}"`);
// Write output
mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, JSON.stringify(out, null, 0));
let totalEntries = 0;
for (const lives of Object.values(out)) totalEntries += lives.length;
console.log(`Converted ${Object.keys(out).length} days, ${totalEntries} entries → ${outputPath}`);
return out;
}
if (import.meta.url === `file://${process.argv[1]}`) {
const epubPath = process.argv[2];
if (!epubPath) {
console.error('Usage: npx tsx scripts/convert-bulgakov.ts <epub-path> [output.json]');
process.exit(1);
}
const outputPath = process.argv[3] || join(process.cwd(), 'scripts', 'output', 'bulgakov.json');
convertBulgakov(epubPath, outputPath).catch(err => {
console.error('Conversion failed:', err);
process.exit(1);
});
}