feat: sva extracted and seeded
This commit is contained in:
@@ -0,0 +1,815 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract Śrī Brahma-saṁhitā verses from other.html into structured JSON files."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
from indic_transliteration import sanscript
|
||||
from indic_transliteration.sanscript import transliterate
|
||||
|
||||
# --- ScaGoudy → IAST Unicode mapping ---
|
||||
SCAGOUDY_TO_IAST = {
|
||||
"ä": "ā",
|
||||
"Ä": "Ā",
|
||||
"å": "ṛ",
|
||||
"Å": "Ṛ",
|
||||
"ñ": "ṣ",
|
||||
"Ñ": "Ṣ",
|
||||
"ö": "ṭ",
|
||||
"Ö": "Ṭ",
|
||||
"ë": "ṇ",
|
||||
"Ë": "Ṇ",
|
||||
"ò": "ḍ",
|
||||
"Ò": "Ḍ",
|
||||
"ç": "ś",
|
||||
"Ç": "Ś",
|
||||
"ì": "ṅ",
|
||||
"ï": "ñ",
|
||||
"ü": "ū",
|
||||
"Ü": "Ū",
|
||||
"é": "ī",
|
||||
"É": "Ī",
|
||||
"à": "ṁ",
|
||||
"ù": "ḥ",
|
||||
"è": "ṝ",
|
||||
"û": "ṁl",
|
||||
"ÿ": "ḷ",
|
||||
}
|
||||
|
||||
# --- Text number to title mapping ---
|
||||
TEXT_TITLES = {n: f"Text {n}" for n in range(1, 63)}
|
||||
|
||||
# --- CSS class constants ---
|
||||
BOOK_HEADER_CLASS = "p12"
|
||||
VERSE_BOOKMARK_CLASS = "p49"
|
||||
VERSE_HEADING_CLASS = "p50"
|
||||
TEXT_LABEL_CLASS = "p52"
|
||||
PADA_TRANSLIT_CLASS = "p5"
|
||||
PADA_TRANSLIT_CONT_CLASS = "p66"
|
||||
SYNONYMS_HEADER_CLASS = "p165"
|
||||
SYNONYM_TEXT_CLASS = "p54"
|
||||
TRANSLATION_LABEL_CLASS = "p55"
|
||||
TRANSLATION_TEXT_CLASS = "p56"
|
||||
PURPORT_LABEL_CLASS_ALT = "p57"
|
||||
PURPORT_BODY_CLASS = "p48"
|
||||
CROSS_REF_CLASS = "p58"
|
||||
QUOTED_VERSE_CLASS = "p53"
|
||||
COMMENTATOR_CLASS = "p44"
|
||||
SECTION_HEADING_CLASS = "p31"
|
||||
INTRO_BODY_CLASS = "p18"
|
||||
FOREWORD_BODY_CLASS = "p6"
|
||||
SIGNATURE_CLASS = "p164"
|
||||
END_MARKER_CLASS = "p166"
|
||||
|
||||
|
||||
def decode_scagoudy(text: str) -> str:
|
||||
"""Convert ScaGoudy-encoded text to proper IAST Unicode."""
|
||||
for src, dst in SCAGOUDY_TO_IAST.items():
|
||||
text = text.replace(src, dst)
|
||||
return text
|
||||
|
||||
|
||||
def decode_html_content(tag: Tag) -> str:
|
||||
"""Extract inner HTML from a tag, decode ScaGoudy, preserve formatting tags."""
|
||||
inner = "".join(str(child) for child in tag.children)
|
||||
inner = decode_scagoudy(inner)
|
||||
# Remove span tags with CSS classes (artifacts from RTF conversion)
|
||||
inner = re.sub(
|
||||
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, flags=re.DOTALL
|
||||
)
|
||||
# Clean up Apple-tab-span leftovers (tab characters)
|
||||
inner = inner.replace("\t", " ")
|
||||
# Normalize whitespace
|
||||
inner = re.sub(r"\s+", " ", inner).strip()
|
||||
return inner
|
||||
|
||||
|
||||
def decode_text(tag: Tag) -> str:
|
||||
"""Extract plain text from a tag and decode ScaGoudy."""
|
||||
text = tag.get_text()
|
||||
return decode_scagoudy(text).strip()
|
||||
|
||||
|
||||
def normalize_devanagari_anusvara(text: str) -> str:
|
||||
"""Replace explicit class nasals before same-class consonants with anusvāra.
|
||||
|
||||
Only normalize ङ् and ञ् — these are almost always written with anusvāra.
|
||||
Keep ण्ड, न्त, म्ब etc. as explicit conjuncts (standard spelling).
|
||||
"""
|
||||
replacements = [
|
||||
# ङ् before velars (क, ख, ग, घ)
|
||||
(r"ङ्([कखगघ])", r"ं\1"),
|
||||
# ञ् before palatals (च, छ, ज, झ)
|
||||
(r"ञ्([चछजझ])", r"ं\1"),
|
||||
]
|
||||
for pattern, repl in replacements:
|
||||
text = re.sub(pattern, repl, text)
|
||||
return text
|
||||
|
||||
|
||||
def join_virama_words(text: str) -> str:
|
||||
"""Join Devanagari words where the previous word ends with virāma (्).
|
||||
|
||||
Handles both virāma+consonant and virāma+vowel cases.
|
||||
Uses [ \\t]+ instead of \\s+ to avoid joining across newlines.
|
||||
"""
|
||||
# Join virāma + space + consonant (क U+0915 through ह U+0939)
|
||||
text = re.sub(r"्[ \t]+([क-ह])", r"्\1", text)
|
||||
|
||||
# Join virāma + space + independent vowel
|
||||
_VOWEL_TO_MATRA = {
|
||||
"अ": "", # inherent vowel — just drop virāma and the अ
|
||||
"आ": "ा",
|
||||
"इ": "ि",
|
||||
"ई": "ी",
|
||||
"उ": "ु",
|
||||
"ऊ": "ू",
|
||||
"ऋ": "ृ",
|
||||
"ॠ": "ॄ",
|
||||
"ऌ": "ॢ",
|
||||
"ॡ": "ॣ",
|
||||
"ए": "े",
|
||||
"ऐ": "ै",
|
||||
"ओ": "ो",
|
||||
"औ": "ौ",
|
||||
}
|
||||
|
||||
def _virama_vowel_replace(m: re.Match[str]) -> str:
|
||||
vowel = m.group(1)
|
||||
return _VOWEL_TO_MATRA.get(vowel, vowel)
|
||||
|
||||
text = re.sub(r"्[ \t]+([अ-औॠॡ])", _virama_vowel_replace, text)
|
||||
return text
|
||||
|
||||
|
||||
def iast_to_devanagari(iast_text: str) -> str:
|
||||
"""Convert IAST transliteration to Devanagari script."""
|
||||
lines = iast_text.split("\n")
|
||||
result_lines = []
|
||||
for line in lines:
|
||||
# Remove hyphens between words for Devanagari
|
||||
clean = line.replace("-", "")
|
||||
# Normalize ṁ (U+1E41) to ṃ (U+1E43) for proper anusvāra
|
||||
clean = clean.replace("ṁ", "ṃ")
|
||||
deva = transliterate(clean, sanscript.IAST, sanscript.DEVANAGARI)
|
||||
deva = normalize_devanagari_anusvara(deva)
|
||||
deva = join_virama_words(deva)
|
||||
result_lines.append(deva)
|
||||
return "\n".join(result_lines)
|
||||
|
||||
|
||||
def parse_synonyms(text: str) -> list:
|
||||
"""Parse synonym text into structured array.
|
||||
|
||||
Input format: "word—meaning; word—meaning; word—meaning"
|
||||
"""
|
||||
result = []
|
||||
entries = text.split(";")
|
||||
for entry in entries:
|
||||
entry = entry.strip()
|
||||
if not entry:
|
||||
continue
|
||||
if "—" in entry:
|
||||
parts = entry.split("—", 1)
|
||||
elif "--" in entry:
|
||||
parts = entry.split("--", 1)
|
||||
else:
|
||||
continue
|
||||
|
||||
if len(parts) == 2:
|
||||
word = parts[0].strip()
|
||||
meaning = parts[1].strip()
|
||||
# Clean word: remove trailing/leading punctuation
|
||||
word = word.strip("., ")
|
||||
if word and meaning:
|
||||
result.append({"word": word, "meanings": [meaning]})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def pair_padas(padas: list) -> str:
|
||||
"""Combine pādas into 2-line verse format.
|
||||
|
||||
If the number of pādas is odd, the first one is treated as a
|
||||
standalone line (e.g. speaker attribution like 'uvāca').
|
||||
|
||||
Empty strings in the list act as stanza-break sentinels and are
|
||||
rendered as blank lines in the output.
|
||||
"""
|
||||
if not padas:
|
||||
return ""
|
||||
|
||||
# Split pādas into stanza groups separated by "" sentinels
|
||||
groups: list[list[str]] = [[]]
|
||||
for p in padas:
|
||||
if p == "":
|
||||
groups.append([])
|
||||
else:
|
||||
groups[-1].append(p)
|
||||
|
||||
result_lines = []
|
||||
for group in groups:
|
||||
if not group:
|
||||
continue
|
||||
|
||||
lines = []
|
||||
i = 0
|
||||
|
||||
# If odd number of pādas, first one is standalone (speaker attribution)
|
||||
if len(group) % 2 == 1:
|
||||
lines.append(group[0])
|
||||
i = 1
|
||||
|
||||
# Pair remaining pādas
|
||||
while i < len(group) - 1:
|
||||
lines.append(group[i] + " " + group[i + 1])
|
||||
i += 2
|
||||
|
||||
# Handle any trailing single pāda
|
||||
if i < len(group):
|
||||
lines.append(group[i])
|
||||
|
||||
result_lines.append("\n".join(lines))
|
||||
|
||||
return "\n\n".join(result_lines)
|
||||
|
||||
|
||||
def get_css_class(tag: Tag) -> str:
|
||||
"""Get the primary CSS class of a tag."""
|
||||
classes = tag.get("class", [])
|
||||
return classes[0] if classes else ""
|
||||
|
||||
|
||||
def is_empty(tag: Tag) -> bool:
|
||||
"""Check if a tag is empty or whitespace-only."""
|
||||
text = tag.get_text().strip()
|
||||
return not text or text == "\xa0"
|
||||
|
||||
|
||||
def write_json(data, filepath):
|
||||
"""Write JSON with proper Unicode and formatting."""
|
||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
print(f" Written: {filepath}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Boundary detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def find_bs_boundaries(all_paragraphs: list) -> tuple[int, int]:
|
||||
"""Find the start and end paragraph indices of the Brahma-saṁhitā content.
|
||||
|
||||
Start: <p class="p12"> containing "Brahma-sa" (ScaGoudy encoded)
|
||||
End: next <p class="p166"> after start (start of next book)
|
||||
"""
|
||||
start_idx = None
|
||||
end_idx = len(all_paragraphs)
|
||||
|
||||
for i, p in enumerate(all_paragraphs):
|
||||
cls = get_css_class(p)
|
||||
if cls == BOOK_HEADER_CLASS and start_idx is None:
|
||||
text = p.get_text().strip()
|
||||
# ScaGoudy-encoded: "Çré Brahma-saàhitä"
|
||||
if "Brahma-sa" in text:
|
||||
start_idx = i
|
||||
continue
|
||||
if start_idx is not None and cls == END_MARKER_CLASS:
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
if start_idx is None:
|
||||
print("ERROR: Could not find 'Śrī Brahma-saṁhitā' header in other.html!")
|
||||
sys.exit(1)
|
||||
|
||||
return start_idx, end_idx
|
||||
|
||||
|
||||
def parse_verse_bookmark(text: str) -> int | None:
|
||||
"""Parse a verse bookmark like 'Bs 5.N'.
|
||||
|
||||
Returns text number (1-62), or None.
|
||||
"""
|
||||
text = text.strip()
|
||||
m = re.match(r"Bs\s+5\.(\d+)", text)
|
||||
if m:
|
||||
return int(m.group(1))
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Section boundary detection within BS range
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def find_section_boundaries(all_paragraphs: list, start_idx: int, end_idx: int) -> dict:
|
||||
"""Find key section boundaries within the BS content.
|
||||
|
||||
Returns dict with section names and their start indices.
|
||||
"""
|
||||
sections = {}
|
||||
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
text = p.get_text().strip()
|
||||
|
||||
# Introduction bookmark
|
||||
if cls == VERSE_BOOKMARK_CLASS and "Bs Introduction" in text:
|
||||
sections["introduction_bookmark"] = i
|
||||
# Foreword bookmark
|
||||
elif cls == VERSE_BOOKMARK_CLASS and "Bs Foreword" in text:
|
||||
sections["foreword_bookmark"] = i
|
||||
# First verse bookmark (Bs 5.1)
|
||||
elif cls == VERSE_BOOKMARK_CLASS and parse_verse_bookmark(decode_text(p)) == 1:
|
||||
sections["first_verse"] = i
|
||||
break
|
||||
|
||||
# Find the chapter heading (p31 with "Chapter Five")
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
if cls == SECTION_HEADING_CLASS:
|
||||
text = p.get_text().strip()
|
||||
if "Chapter Five" in text or "Chapter" in text:
|
||||
# Check if this is the chapter title, not intro/foreword headers
|
||||
if i > sections.get("foreword_bookmark", 0):
|
||||
sections["chapter_heading"] = i
|
||||
break
|
||||
|
||||
return sections
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Intro segment extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_intro_segment(
|
||||
all_paragraphs: list, start_idx: int, end_idx: int, body_classes: tuple
|
||||
) -> list[str]:
|
||||
"""Extract paragraphs for an intro segment.
|
||||
|
||||
Decodes ScaGoudy, preserves HTML formatting, skips empty paragraphs.
|
||||
"""
|
||||
result = []
|
||||
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
|
||||
if is_empty(p):
|
||||
continue
|
||||
|
||||
# Skip bookmark and header classes
|
||||
if cls in (VERSE_BOOKMARK_CLASS, BOOK_HEADER_CLASS, SECTION_HEADING_CLASS):
|
||||
continue
|
||||
|
||||
# Include specified body classes and signature class
|
||||
if cls in body_classes or cls == SIGNATURE_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verse extraction state machine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_verses(all_paragraphs: list, verses_start: int, verses_end: int) -> list:
|
||||
"""Extract verses from the BS Chapter Five section using a state machine.
|
||||
|
||||
States: IDLE → VERSE_START → TRANSLITERATION → SYNONYMS →
|
||||
TRANSLATION → POST_TRANSLATION → PURPORT → (back to IDLE)
|
||||
|
||||
Returns list of verse dicts.
|
||||
"""
|
||||
verses = []
|
||||
current_verse = None
|
||||
state = "IDLE"
|
||||
|
||||
for i in range(verses_start, verses_end):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
|
||||
# --- Verse bookmark (p49) ---
|
||||
if cls == VERSE_BOOKMARK_CLASS:
|
||||
# Finalize previous verse if any
|
||||
if current_verse and state != "IDLE":
|
||||
_finalize_verse(current_verse)
|
||||
verses.append(current_verse)
|
||||
|
||||
text = decode_text(p)
|
||||
verse_num = parse_verse_bookmark(text)
|
||||
if verse_num is not None:
|
||||
reference = f"Bs 5.{verse_num}"
|
||||
|
||||
current_verse = {
|
||||
"book_title": "Śrī Brahma-saṁhitā",
|
||||
"chapter": 5,
|
||||
"verse": verse_num,
|
||||
"reference": reference,
|
||||
"devanagari": "",
|
||||
"transliteration": "",
|
||||
"_padas": [],
|
||||
"synonyms": [],
|
||||
"translation": "",
|
||||
"purport": [],
|
||||
}
|
||||
state = "VERSE_START"
|
||||
continue
|
||||
|
||||
if not current_verse:
|
||||
continue
|
||||
|
||||
# --- Verse heading (p50) - just confirms the text number ---
|
||||
if cls == VERSE_HEADING_CLASS:
|
||||
continue
|
||||
|
||||
# --- TEXT label (p52) - transition to transliteration ---
|
||||
if cls == TEXT_LABEL_CLASS:
|
||||
state = "TRANSLITERATION"
|
||||
continue
|
||||
|
||||
# --- Pāda transliteration lines (p5 and p66) ---
|
||||
if cls in (PADA_TRANSLIT_CLASS, PADA_TRANSLIT_CONT_CLASS) and state in (
|
||||
"VERSE_START",
|
||||
"TRANSLITERATION",
|
||||
):
|
||||
state = "TRANSLITERATION"
|
||||
line = decode_text(p)
|
||||
# Remove trailing period
|
||||
line = line.rstrip(".")
|
||||
line = line.strip()
|
||||
if line:
|
||||
# p66 marks the start of a new stanza group — insert a blank
|
||||
# sentinel so _finalize_verse preserves the line break.
|
||||
if cls == PADA_TRANSLIT_CONT_CLASS and current_verse["_padas"]:
|
||||
current_verse["_padas"].append("")
|
||||
current_verse["_padas"].append(line)
|
||||
continue
|
||||
|
||||
# --- SYNONYMS header (p165) ---
|
||||
if cls == SYNONYMS_HEADER_CLASS:
|
||||
state = "SYNONYMS"
|
||||
continue
|
||||
|
||||
# --- Synonyms text (p54) ---
|
||||
if cls == SYNONYM_TEXT_CLASS and current_verse:
|
||||
state = "SYNONYMS"
|
||||
syn_text = decode_text(p)
|
||||
syn_text = syn_text.strip()
|
||||
parsed = parse_synonyms(syn_text)
|
||||
current_verse["synonyms"].extend(parsed)
|
||||
continue
|
||||
|
||||
# --- TRANSLATION label (p55) — check text content to distinguish from PURPORT ---
|
||||
if cls == TRANSLATION_LABEL_CLASS and current_verse:
|
||||
inner_text = p.get_text().strip().upper()
|
||||
if "TRANSLATION" in inner_text:
|
||||
state = "TRANSLATION"
|
||||
elif "PURPORT" in inner_text:
|
||||
state = "PURPORT"
|
||||
continue
|
||||
|
||||
# --- PURPORT label via p57 ---
|
||||
if cls == PURPORT_LABEL_CLASS_ALT and current_verse:
|
||||
inner_text = p.get_text().strip().upper()
|
||||
if "PURPORT" in inner_text:
|
||||
state = "PURPORT"
|
||||
elif "TRANSLATION" in inner_text:
|
||||
state = "TRANSLATION"
|
||||
continue
|
||||
|
||||
# --- Translation text (p56) ---
|
||||
if cls == TRANSLATION_TEXT_CLASS and current_verse and state == "TRANSLATION":
|
||||
html = decode_html_content(p)
|
||||
# Remove bold wrapper if present (the whole translation is bold)
|
||||
html = re.sub(r"^<b>(.*)</b>$", r"\1", html, flags=re.DOTALL)
|
||||
html = html.strip()
|
||||
current_verse["translation"] = html
|
||||
state = "POST_TRANSLATION"
|
||||
continue
|
||||
|
||||
# --- Purport paragraphs (p48, p6) ---
|
||||
if (
|
||||
cls in (PURPORT_BODY_CLASS, FOREWORD_BODY_CLASS)
|
||||
and current_verse
|
||||
and state in ("PURPORT", "POST_TRANSLATION")
|
||||
):
|
||||
state = "PURPORT"
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
current_verse["purport"].append(html)
|
||||
continue
|
||||
|
||||
# --- Cross-references (p58) — treat as purport paragraph ---
|
||||
if (
|
||||
cls == CROSS_REF_CLASS
|
||||
and current_verse
|
||||
and state in ("PURPORT", "POST_TRANSLATION")
|
||||
):
|
||||
state = "PURPORT"
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
current_verse["purport"].append(html)
|
||||
continue
|
||||
|
||||
# --- Quoted verse in purport (p53) — treat as purport paragraph ---
|
||||
if cls == QUOTED_VERSE_CLASS and current_verse and state == "PURPORT":
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
current_verse["purport"].append(html)
|
||||
continue
|
||||
|
||||
# --- Commentator text in purport (p44) — treat as purport paragraph ---
|
||||
if cls == COMMENTATOR_CLASS and current_verse and state == "PURPORT":
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
current_verse["purport"].append(html)
|
||||
continue
|
||||
|
||||
# --- Section heading within purport (p31) ---
|
||||
if cls == SECTION_HEADING_CLASS and current_verse and state == "PURPORT":
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
current_verse["purport"].append(html)
|
||||
continue
|
||||
|
||||
# Finalize last verse
|
||||
if current_verse and state != "IDLE":
|
||||
_finalize_verse(current_verse)
|
||||
verses.append(current_verse)
|
||||
|
||||
return verses
|
||||
|
||||
|
||||
def _finalize_verse(verse: dict):
|
||||
"""Build transliteration and Devanagari from pādas. Remove internal fields."""
|
||||
padas = verse.get("_padas", [])
|
||||
|
||||
# Transliteration: one pāda per line, blank sentinels become blank lines
|
||||
trans_lines = []
|
||||
for p in padas:
|
||||
if p == "":
|
||||
trans_lines.append("") # blank line between stanza groups
|
||||
else:
|
||||
trans_lines.append(p)
|
||||
verse["transliteration"] = "\n".join(trans_lines)
|
||||
|
||||
# Devanagari: pair pādas into 2-line verse structure, then convert
|
||||
if padas:
|
||||
paired = pair_padas(padas)
|
||||
verse["devanagari"] = iast_to_devanagari(paired)
|
||||
|
||||
# Clean up internal field
|
||||
if "_padas" in verse:
|
||||
del verse["_padas"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main():
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
html_path = os.path.join(base_dir, "public", "html", "other.html")
|
||||
output_base = os.path.join(base_dir, "lib", "jsons", "books", "bs")
|
||||
|
||||
if not os.path.exists(html_path):
|
||||
print(f"ERROR: {html_path} not found!")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Reading {html_path}...")
|
||||
with open(html_path, "r", encoding="utf-8") as f:
|
||||
soup = BeautifulSoup(f, "html.parser")
|
||||
|
||||
all_paragraphs = soup.find_all("p")
|
||||
print(f"Total paragraphs in file: {len(all_paragraphs)}")
|
||||
|
||||
# --- Find BS boundaries ---
|
||||
bs_start, bs_end = find_bs_boundaries(all_paragraphs)
|
||||
print(f"BS content: paragraphs {bs_start} to {bs_end}")
|
||||
|
||||
# --- Find section boundaries ---
|
||||
sections = find_section_boundaries(all_paragraphs, bs_start, bs_end)
|
||||
print(f"\nSection boundaries found:")
|
||||
for name, idx in sorted(sections.items(), key=lambda x: x[1]):
|
||||
print(f" [{idx}] {name}")
|
||||
|
||||
intro_bm = sections.get("introduction_bookmark")
|
||||
foreword_bm = sections.get("foreword_bookmark")
|
||||
chapter_heading = sections.get("chapter_heading")
|
||||
first_verse = sections.get("first_verse")
|
||||
|
||||
if first_verse is None:
|
||||
print("ERROR: Could not find first verse bookmark (Bs 5.1)!")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Extract intro segments ---
|
||||
print("\n--- Extracting Intro Segments ---")
|
||||
|
||||
intro_segments = []
|
||||
|
||||
# 1. Introduction segment
|
||||
if intro_bm is not None:
|
||||
intro_end = foreword_bm if foreword_bm is not None else first_verse
|
||||
intro_paras = extract_intro_segment(
|
||||
all_paragraphs, intro_bm, intro_end, (INTRO_BODY_CLASS,)
|
||||
)
|
||||
intro_segment = {
|
||||
"slug": "introduction",
|
||||
"title": "Introduction",
|
||||
"type": "intro-segment",
|
||||
"book": "bs",
|
||||
"order": 1,
|
||||
"paragraphs": intro_paras,
|
||||
}
|
||||
intro_segments.append(intro_segment)
|
||||
print(f" Introduction: {len(intro_paras)} paragraphs")
|
||||
|
||||
# 2. Foreword segment
|
||||
if foreword_bm is not None:
|
||||
foreword_end = chapter_heading if chapter_heading is not None else first_verse
|
||||
foreword_paras = extract_intro_segment(
|
||||
all_paragraphs, foreword_bm, foreword_end, (FOREWORD_BODY_CLASS,)
|
||||
)
|
||||
foreword_segment = {
|
||||
"slug": "foreword",
|
||||
"title": "Foreword",
|
||||
"type": "intro-segment",
|
||||
"book": "bs",
|
||||
"order": 2,
|
||||
"paragraphs": foreword_paras,
|
||||
}
|
||||
intro_segments.append(foreword_segment)
|
||||
print(f" Foreword: {len(foreword_paras)} paragraphs")
|
||||
|
||||
# --- Extract verses ---
|
||||
print("\n--- Extracting Verses ---")
|
||||
|
||||
# Verse section runs from the first verse bookmark to bs_end
|
||||
verses = extract_verses(all_paragraphs, first_verse, bs_end)
|
||||
print(f"Extracted {len(verses)} verses (expected 62: Texts 1-62)")
|
||||
|
||||
# --- Write intro.json ---
|
||||
intro_meta = {
|
||||
"book": "bs",
|
||||
"title": "Śrī Brahma-saṁhitā",
|
||||
"author": "Śrīla Bhaktisiddhānta Sarasvatī Ṭhākura",
|
||||
"year": 1932,
|
||||
"description": "Chapter Five of the Hymns of Brahmā, discovered by Śrī Caitanya Mahāprabhu. Commentary by Śrīla Bhaktisiddhānta Sarasvatī Ṭhākura.",
|
||||
"totalChapters": 1,
|
||||
"chapters": [
|
||||
{
|
||||
"number": 5,
|
||||
"title": "Chapter Five",
|
||||
"totalVerses": 62,
|
||||
}
|
||||
],
|
||||
}
|
||||
write_json(intro_meta, os.path.join(output_base, "intro.json"))
|
||||
|
||||
# --- Write intro segments ---
|
||||
print("\n--- Writing Intro Segments ---")
|
||||
for seg in intro_segments:
|
||||
filepath = os.path.join(output_base, "intro", f"{seg['slug']}.json")
|
||||
write_json(seg, filepath)
|
||||
|
||||
# --- Write verse JSON files ---
|
||||
print("\n--- Writing Verse Files ---")
|
||||
for verse in verses:
|
||||
text_num = verse["verse"]
|
||||
filename = f"{str(text_num).zfill(2)}.json"
|
||||
filepath = os.path.join(output_base, "chapters", "05", "verses", filename)
|
||||
write_json(verse, filepath)
|
||||
|
||||
# --- Summary ---
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" EXTRACTION SUMMARY")
|
||||
print(f"{'=' * 60}")
|
||||
print(f" Book: Śrī Brahma-saṁhitā")
|
||||
print(f" Intro segments: {len(intro_segments)}")
|
||||
for seg in intro_segments:
|
||||
print(
|
||||
f" {seg['order']}. {seg['title']}: {len(seg['paragraphs'])} paragraphs"
|
||||
)
|
||||
print(f" Verses extracted: {len(verses)}")
|
||||
texts_found = sorted([v["verse"] for v in verses])
|
||||
if texts_found:
|
||||
print(
|
||||
f" Texts: {texts_found[0]}-{texts_found[-1]} ({len(texts_found)} total)"
|
||||
)
|
||||
else:
|
||||
print(" Texts: None")
|
||||
|
||||
total_purport_paras = sum(len(v["purport"]) for v in verses)
|
||||
total_synonyms = sum(len(v["synonyms"]) for v in verses)
|
||||
print(f" Total purport ¶s: {total_purport_paras}")
|
||||
print(f" Total synonyms: {total_synonyms}")
|
||||
print(f" Output directory: {output_base}/")
|
||||
|
||||
# --- Quality checks ---
|
||||
print(f"\n--- Quality Checks ---")
|
||||
|
||||
# Check all verses have expected fields
|
||||
issues = []
|
||||
for verse in verses:
|
||||
ref = verse["reference"]
|
||||
if not verse["transliteration"]:
|
||||
issues.append(f" {ref}: missing transliteration")
|
||||
if not verse["devanagari"]:
|
||||
issues.append(f" {ref}: missing devanagari")
|
||||
if not verse["synonyms"]:
|
||||
issues.append(f" {ref}: missing synonyms")
|
||||
if not verse["translation"]:
|
||||
issues.append(f" {ref}: missing translation")
|
||||
# Not all verses necessarily have purport, but most do
|
||||
if not verse["purport"]:
|
||||
issues.append(f" {ref}: missing purport (may be intentional)")
|
||||
|
||||
if issues:
|
||||
print(" ISSUES FOUND:")
|
||||
for issue in issues:
|
||||
print(issue)
|
||||
else:
|
||||
print(
|
||||
" All verses have transliteration, devanagari, synonyms, and translation ✓"
|
||||
)
|
||||
|
||||
# Check expected verse count
|
||||
expected_texts = set(range(1, 63))
|
||||
found_texts = set(v["verse"] for v in verses)
|
||||
missing = expected_texts - found_texts
|
||||
extra = found_texts - expected_texts
|
||||
if missing:
|
||||
print(f" MISSING texts: {sorted(missing)}")
|
||||
if extra:
|
||||
print(f" UNEXPECTED texts: {sorted(extra)}")
|
||||
if not missing and not extra:
|
||||
print(f" All 62 texts found ✓")
|
||||
|
||||
# --- Samples ---
|
||||
print(f"\n--- Sample: Text 1 (first) ---")
|
||||
t1 = [v for v in verses if v["verse"] == 1]
|
||||
if t1:
|
||||
v = t1[0]
|
||||
print(f" Reference: {v['reference']}")
|
||||
print(f" Transliteration: {v['transliteration'][:80]}...")
|
||||
print(f" Devanagari: {v['devanagari'][:80]}...")
|
||||
print(f" Synonyms: {len(v['synonyms'])} entries")
|
||||
if v["synonyms"]:
|
||||
print(f" First: {v['synonyms'][0]}")
|
||||
trans_preview = re.sub(r"<[^>]+>", "", v["translation"])[:100]
|
||||
print(f" Translation: {trans_preview}...")
|
||||
print(f" Purport ¶s: {len(v['purport'])}")
|
||||
|
||||
print(f"\n--- Sample: Text 29 (middle) ---")
|
||||
t29 = [v for v in verses if v["verse"] == 29]
|
||||
if t29:
|
||||
v = t29[0]
|
||||
print(f" Reference: {v['reference']}")
|
||||
print(f" Transliteration: {v['transliteration'][:80]}...")
|
||||
print(f" Devanagari: {v['devanagari'][:80]}...")
|
||||
print(f" Synonyms: {len(v['synonyms'])} entries")
|
||||
if v["synonyms"]:
|
||||
print(f" First: {v['synonyms'][0]}")
|
||||
print(f" Last: {v['synonyms'][-1]}")
|
||||
trans_preview = re.sub(r"<[^>]+>", "", v["translation"])[:100]
|
||||
print(f" Translation: {trans_preview}...")
|
||||
print(f" Purport ¶s: {len(v['purport'])}")
|
||||
|
||||
print(f"\n--- Sample: Text 62 (last) ---")
|
||||
t62 = [v for v in verses if v["verse"] == 62]
|
||||
if t62:
|
||||
v = t62[0]
|
||||
print(f" Reference: {v['reference']}")
|
||||
print(f" Transliteration: {v['transliteration'][:80]}...")
|
||||
print(f" Devanagari: {v['devanagari'][:80]}...")
|
||||
print(f" Synonyms: {len(v['synonyms'])} entries")
|
||||
trans_preview = re.sub(r"<[^>]+>", "", v["translation"])[:100]
|
||||
print(f" Translation: {trans_preview}...")
|
||||
print(f" Purport ¶s: {len(v['purport'])}")
|
||||
|
||||
# --- Intro segment paragraph counts ---
|
||||
print(f"\n--- Intro Segment Details ---")
|
||||
for seg in intro_segments:
|
||||
print(f" {seg['title']}: {len(seg['paragraphs'])} paragraphs")
|
||||
if seg["paragraphs"]:
|
||||
first_preview = re.sub(r"<[^>]+>", "", seg["paragraphs"][0])[:100]
|
||||
print(f" First ¶: {first_preview}...")
|
||||
last_preview = re.sub(r"<[^>]+>", "", seg["paragraphs"][-1])[:100]
|
||||
print(f" Last ¶: {last_preview}...")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,845 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract Gītār Gān (GG) from other.html.
|
||||
|
||||
Gītār Gān is a Bengali poetic rendering of Bhagavad-gītā As It Is.
|
||||
It has 1 intro segment (introduction) and 18 chapters.
|
||||
|
||||
The content is primarily Bengali verse text (p24, italic with <br> line breaks)
|
||||
interleaved with text number headings (p224, bold).
|
||||
|
||||
Output:
|
||||
lib/jsons/books/gg/intro.json — Book metadata
|
||||
lib/jsons/books/gg/intro/*.json — Intro segments
|
||||
lib/jsons/books/gg/chapters/*.json — Chapters (01–18)
|
||||
|
||||
Usage:
|
||||
python3 scripts/gg_extract.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ScaGoudy → IAST Unicode mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
SCAGOUDY_TO_IAST = {
|
||||
"ä": "ā",
|
||||
"Ä": "Ā",
|
||||
"å": "ṛ",
|
||||
"Å": "Ṛ",
|
||||
"ñ": "ṣ",
|
||||
"Ñ": "Ṣ",
|
||||
"ö": "ṭ",
|
||||
"Ö": "Ṭ",
|
||||
"ë": "ṇ",
|
||||
"Ë": "Ṇ",
|
||||
"ò": "ḍ",
|
||||
"Ò": "Ḍ",
|
||||
"ç": "ś",
|
||||
"Ç": "Ś",
|
||||
"ì": "ṅ",
|
||||
"ï": "ñ",
|
||||
"ü": "ū",
|
||||
"Ü": "Ū",
|
||||
"é": "ī",
|
||||
"É": "Ī",
|
||||
"à": "ṁ",
|
||||
"ù": "ḥ",
|
||||
"è": "ṝ",
|
||||
"û": "ṁl",
|
||||
"ÿ": "ḷ",
|
||||
}
|
||||
|
||||
|
||||
def decode_scagoudy(text: str) -> str:
|
||||
"""Replace ScaGoudy-encoded characters with proper IAST Unicode."""
|
||||
for src, dst in SCAGOUDY_TO_IAST.items():
|
||||
text = text.replace(src, dst)
|
||||
return text
|
||||
|
||||
|
||||
def decode_html_content(tag: Tag) -> str:
|
||||
"""Extract inner HTML from a tag, decode ScaGoudy, preserve formatting tags."""
|
||||
inner = "".join(str(child) for child in tag.children)
|
||||
inner = decode_scagoudy(inner)
|
||||
# Remove span tags with CSS classes (RTF conversion artifacts)
|
||||
inner = re.sub(
|
||||
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, flags=re.DOTALL
|
||||
)
|
||||
# Clean up Apple-tab-span leftovers (tab characters)
|
||||
inner = inner.replace("\t", " ")
|
||||
# Normalize whitespace (but preserve <br> tags)
|
||||
# First, protect <br> tags
|
||||
inner = re.sub(r"<br\s*/?>", "<br/>", inner)
|
||||
# Normalize spaces around text (not across <br>)
|
||||
parts = inner.split("<br/>")
|
||||
parts = [re.sub(r"\s+", " ", p).strip() for p in parts]
|
||||
inner = "<br/>".join(parts)
|
||||
return inner.strip()
|
||||
|
||||
|
||||
def decode_text(tag: Tag) -> str:
|
||||
"""Get plain text from a tag with ScaGoudy decoding."""
|
||||
return decode_scagoudy(tag.get_text().strip())
|
||||
|
||||
|
||||
def get_css_class(tag: Tag) -> str:
|
||||
"""Get the first CSS class of a tag, or empty string."""
|
||||
classes = tag.get("class", [])
|
||||
return classes[0] if classes else ""
|
||||
|
||||
|
||||
def is_empty(tag: Tag) -> bool:
|
||||
"""Check if a tag is empty or whitespace-only."""
|
||||
text = tag.get_text().strip()
|
||||
return not text or text == "\xa0"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSS class categorization for GG
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Book title header class
|
||||
BOOK_HEADER_CLASS = "p12"
|
||||
|
||||
# Subtitle class
|
||||
SUBTITLE_CLASS = "p31"
|
||||
|
||||
# Bookmark class
|
||||
BOOKMARK_CLASS = "p13"
|
||||
|
||||
# Introduction heading / Chapter heading
|
||||
HEADING_CLASS = "p27"
|
||||
|
||||
# Introduction body text
|
||||
BODY_CLASS = "p6"
|
||||
|
||||
# Signature block
|
||||
SIGNATURE_CLASS = "p222"
|
||||
|
||||
# Chapter title heading (contains Chapter N + Title)
|
||||
CHAPTER_TITLE_CLASS = "p223"
|
||||
|
||||
# Bengali verse text (italic, with <br> line breaks — THE MAIN content)
|
||||
VERSE_CLASS = "p24"
|
||||
|
||||
# Text number headings (bold)
|
||||
TEXT_HEADING_CLASS = "p224"
|
||||
|
||||
# Empty/break classes to skip
|
||||
SKIP_CLASSES = frozenset(["p225", "p226", "p15"])
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chapter titles mapping (from bookmarks)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CHAPTER_TITLES = {
|
||||
1: "Observing the Armies on the Battlefield of Kurukṣetra",
|
||||
2: "Contents of the Gītā Summarized",
|
||||
3: "Karma-yoga",
|
||||
4: "Transcendental Knowledge",
|
||||
5: "Karma-yoga—Action in Kṛṣṇa Consciousness",
|
||||
6: "Dhyāna-yoga",
|
||||
7: "Knowledge of the Absolute",
|
||||
8: "Attaining the Supreme",
|
||||
9: "The Most Confidential Knowledge",
|
||||
10: "The Opulence of the Absolute",
|
||||
11: "The Universal Form",
|
||||
12: "Devotional Service",
|
||||
13: "Nature, the Enjoyer, and Consciousness",
|
||||
14: "The Three Modes of Material Nature",
|
||||
15: "The Yoga of the Supreme Person",
|
||||
16: "The Divine and Demoniac Natures",
|
||||
17: "The Divisions of Faith",
|
||||
18: "Conclusion—The Perfection of Renunciation",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extraction helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def find_gg_boundaries(all_paragraphs: list) -> tuple:
|
||||
"""Find the start and end paragraph indices of the GG content.
|
||||
|
||||
Start: <p class="p12"><b>Gétär Gän</b></p> (or decoded: Gītār Gān)
|
||||
End: Next <p class="p12"> after GG content (Songs of the Vaiṣṇava Ācāryas)
|
||||
"""
|
||||
start_idx = None
|
||||
end_idx = len(all_paragraphs)
|
||||
|
||||
for i, p in enumerate(all_paragraphs):
|
||||
cls = get_css_class(p)
|
||||
text = p.get_text().strip()
|
||||
decoded = decode_scagoudy(text)
|
||||
|
||||
if cls == BOOK_HEADER_CLASS:
|
||||
if start_idx is None:
|
||||
# Look for "Gétär Gän" or decoded "Gītār Gān"
|
||||
if (
|
||||
"Gétär Gän" in text
|
||||
or "Gītār Gān" in decoded
|
||||
or "G\u00e9t\u00e4r G\u00e4n" in text
|
||||
):
|
||||
start_idx = i
|
||||
continue
|
||||
else:
|
||||
# Next p12 after start marks the end
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
if start_idx is None:
|
||||
print("ERROR: Could not find 'Gītār Gān' header in other.html!")
|
||||
sys.exit(1)
|
||||
|
||||
return start_idx, end_idx
|
||||
|
||||
|
||||
def find_all_bookmarks(all_paragraphs: list, start_idx: int, end_idx: int) -> list:
|
||||
"""Find all GG bookmark tags in the GG range.
|
||||
|
||||
Bookmarks are p13 class with text starting with "GG:".
|
||||
Returns list of (paragraph_index, bookmark_text).
|
||||
"""
|
||||
bookmarks = []
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
if cls != BOOKMARK_CLASS:
|
||||
continue
|
||||
text = decode_text(p).strip()
|
||||
if text.startswith("GG:"):
|
||||
bookmarks.append((i, text))
|
||||
return bookmarks
|
||||
|
||||
|
||||
def parse_chapter_bookmark(text: str):
|
||||
"""Parse a chapter bookmark like 'GG: Chapter 1'.
|
||||
|
||||
Returns chapter_number (int) or None if not a chapter bookmark.
|
||||
"""
|
||||
m = re.match(r"GG:\s+Chapter\s+(\d+)", text.strip())
|
||||
if m:
|
||||
return int(m.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def extract_chapter_title(tag: Tag) -> tuple:
|
||||
"""Extract chapter number and title from a p223 element.
|
||||
|
||||
The p223 contains something like:
|
||||
<span class="s41"><b>Chapter 1</b></span><b>Observing the Armies...</b>
|
||||
|
||||
Returns (chapter_number, title_text) or None.
|
||||
"""
|
||||
text = decode_text(tag)
|
||||
# Try to parse "Chapter N\nTitle" or "Chapter N Title"
|
||||
m = re.match(r"Chapter\s+(\d+)\s*(.*)", text, re.DOTALL)
|
||||
if m:
|
||||
ch_num = int(m.group(1))
|
||||
title = m.group(2).strip()
|
||||
# Clean up any leading/trailing whitespace or newlines
|
||||
title = re.sub(r"\s+", " ", title).strip()
|
||||
return ch_num, title
|
||||
return None
|
||||
|
||||
|
||||
def extract_verse_content(tag: Tag) -> str:
|
||||
"""Extract Bengali verse content from a p24 tag.
|
||||
|
||||
The content is italic with <br> line breaks. We wrap it as a centered
|
||||
italic div: <div style="text-align:center"><i>content</i></div>
|
||||
|
||||
ScaGoudy decoding is applied.
|
||||
"""
|
||||
html = decode_html_content(tag)
|
||||
if not html:
|
||||
return ""
|
||||
|
||||
# Strip outer <i> tags if present — we'll re-wrap
|
||||
stripped = re.sub(r"^<i>(.*)</i>$", r"\1", html, flags=re.DOTALL)
|
||||
# Also remove any remaining <i>/<\/i> pairs within
|
||||
stripped = re.sub(r"</?i>", "", stripped)
|
||||
|
||||
if stripped.strip():
|
||||
return f'<div style="text-align:center"><i>{stripped.strip()}</i></div>'
|
||||
return ""
|
||||
|
||||
|
||||
def extract_text_heading(tag: Tag) -> tuple:
|
||||
"""Extract text heading from a p224 tag.
|
||||
|
||||
Content is bold, e.g. <b>Text 1</b> or <b>Texts 16-18</b>
|
||||
or closing: <b>Thus Bhaktivedānta sings...</b>
|
||||
|
||||
Some headings have the translation merged in, like:
|
||||
<b>Text 30: All these performers who know...</b>
|
||||
These are split: the text number is returned as the heading,
|
||||
and the translation is returned separately as a "deferred" paragraph
|
||||
to be emitted AFTER the next verse block.
|
||||
|
||||
Returns (heading_html, deferred_translation_html_or_None).
|
||||
"""
|
||||
html = decode_html_content(tag)
|
||||
if not html:
|
||||
return ("", None)
|
||||
|
||||
stripped = html.strip()
|
||||
|
||||
# Remove outer <b> to inspect inner text
|
||||
inner = stripped
|
||||
if inner.startswith("<b>") and inner.endswith("</b>"):
|
||||
inner = inner[3:-4].strip()
|
||||
|
||||
# Check if this is a merged "Text N: translation..." heading
|
||||
m = re.match(r"^(Texts?\s+[\d\-–]+)\s*[:\.]\s*(.+)$", inner, re.DOTALL)
|
||||
if m:
|
||||
text_num = m.group(1).strip()
|
||||
translation = m.group(2).strip()
|
||||
heading = f'<div style="text-align:center"><b>{text_num}</b></div>'
|
||||
deferred = f'<div style="text-align:center"><b>{translation}</b></div>'
|
||||
return (heading, deferred)
|
||||
|
||||
# Plain text heading (e.g. "Text 1") or closing statement — center it
|
||||
if not stripped.startswith("<b>"):
|
||||
stripped = f"<b>{stripped}</b>"
|
||||
return (f'<div style="text-align:center">{stripped}</div>', None)
|
||||
|
||||
|
||||
def extract_chapter_heading(tag: Tag) -> str:
|
||||
"""Extract chapter heading from a p223 tag as a bold paragraph.
|
||||
|
||||
Returns something like: <b>Chapter 1<br/>Observing the Armies...</b>
|
||||
"""
|
||||
html = decode_html_content(tag)
|
||||
if not html:
|
||||
return ""
|
||||
# Clean up the HTML — remove inner bold tags and re-wrap the whole thing
|
||||
inner = re.sub(r"</?b>", "", html)
|
||||
inner = inner.strip()
|
||||
# Try to add a <br/> between "Chapter N" and the title if not already there
|
||||
inner = re.sub(r"(Chapter\s+\d+)\s+", r"\1<br/>", inner, count=1)
|
||||
return f"<b>{inner}</b>"
|
||||
|
||||
|
||||
def extract_paragraphs_for_intro(
|
||||
all_paragraphs: list,
|
||||
start_idx: int,
|
||||
end_idx: int,
|
||||
) -> list:
|
||||
"""Extract paragraphs for the introduction section.
|
||||
|
||||
Handles p27 (heading), p6 (body text), p222 (signature block).
|
||||
"""
|
||||
result = []
|
||||
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
|
||||
# Skip empty paragraphs
|
||||
if is_empty(p):
|
||||
continue
|
||||
|
||||
# Skip noise classes
|
||||
if cls in SKIP_CLASSES:
|
||||
continue
|
||||
|
||||
# Skip bookmark tags
|
||||
if cls == BOOKMARK_CLASS:
|
||||
continue
|
||||
|
||||
# Skip book header (p12)
|
||||
if cls == BOOK_HEADER_CLASS:
|
||||
continue
|
||||
|
||||
# Skip subtitle (p31)
|
||||
if cls == SUBTITLE_CLASS:
|
||||
continue
|
||||
|
||||
# Introduction heading (p27)
|
||||
if cls == HEADING_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Body text (p6)
|
||||
if cls == BODY_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Signature block (p222)
|
||||
if cls == SIGNATURE_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Any other class in intro range — try to include
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def extract_paragraphs_for_chapter(
|
||||
all_paragraphs: list,
|
||||
start_idx: int,
|
||||
end_idx: int,
|
||||
) -> list:
|
||||
"""Extract paragraphs for a chapter.
|
||||
|
||||
Handles:
|
||||
p223 — Chapter heading (bold paragraph)
|
||||
p24 — Bengali verse text (centered italic div)
|
||||
p224 — Text number heading (bold paragraph)
|
||||
p225, p226, p15 — Skip (empty/break)
|
||||
"""
|
||||
result = []
|
||||
deferred_translation = None
|
||||
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
|
||||
# Skip empty paragraphs
|
||||
if is_empty(p):
|
||||
continue
|
||||
|
||||
# Skip noise classes
|
||||
if cls in SKIP_CLASSES:
|
||||
continue
|
||||
|
||||
# Skip bookmark tags
|
||||
if cls == BOOKMARK_CLASS:
|
||||
continue
|
||||
|
||||
# Chapter title heading (p223)
|
||||
if cls == CHAPTER_TITLE_CLASS:
|
||||
heading = extract_chapter_heading(p)
|
||||
if heading:
|
||||
result.append(heading)
|
||||
continue
|
||||
|
||||
# Bengali verse text (p24)
|
||||
if cls == VERSE_CLASS:
|
||||
verse = extract_verse_content(p)
|
||||
if verse:
|
||||
result.append(verse)
|
||||
# Emit deferred translation AFTER the verse
|
||||
if deferred_translation:
|
||||
result.append(deferred_translation)
|
||||
deferred_translation = None
|
||||
continue
|
||||
|
||||
# Text number heading (p224)
|
||||
if cls == TEXT_HEADING_CLASS:
|
||||
# If there's a deferred translation from a previous heading,
|
||||
# emit it now (before this new heading)
|
||||
if deferred_translation:
|
||||
result.append(deferred_translation)
|
||||
deferred_translation = None
|
||||
heading, deferred = extract_text_heading(p)
|
||||
if heading:
|
||||
result.append(heading)
|
||||
if deferred:
|
||||
# Hold the translation — it will be emitted after the next verse block
|
||||
deferred_translation = deferred
|
||||
continue
|
||||
|
||||
# Heading class (p27) — may appear within chapters
|
||||
if cls == HEADING_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
if not html.strip().startswith("<b>"):
|
||||
html = f"<b>{html}</b>"
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Body text (p6) — may appear within chapters
|
||||
if cls == BODY_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Any other class — try to extract content (be permissive)
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
|
||||
# Flush any remaining deferred translation at end of chapter
|
||||
if deferred_translation:
|
||||
result.append(deferred_translation)
|
||||
deferred_translation = None
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main extraction logic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_gg(soup: BeautifulSoup) -> dict:
|
||||
"""Extract the entire Gītār Gān from the parsed HTML.
|
||||
|
||||
Returns a dict with:
|
||||
"intro_segments": list of intro segment dicts
|
||||
"chapters": list of chapter dicts
|
||||
"chapter_listing": list of {number, title} for intro.json
|
||||
"""
|
||||
all_paragraphs = list(soup.find_all("p"))
|
||||
|
||||
# Step 1: Find GG boundaries
|
||||
gg_start, gg_end = find_gg_boundaries(all_paragraphs)
|
||||
print(f" GG content: paragraphs {gg_start} to {gg_end}")
|
||||
print(f" Total paragraphs in range: {gg_end - gg_start}")
|
||||
|
||||
# Step 2: Find all bookmarks
|
||||
bookmarks = find_all_bookmarks(all_paragraphs, gg_start, gg_end)
|
||||
print(f" Found {len(bookmarks)} GG bookmarks")
|
||||
for pos, text in bookmarks:
|
||||
print(f" Para ~{pos}: {text}")
|
||||
|
||||
# Step 3: Categorize bookmarks
|
||||
chapter_bookmarks = [] # (para_idx, chapter_num)
|
||||
|
||||
for pos, text in bookmarks:
|
||||
ch_num = parse_chapter_bookmark(text)
|
||||
if ch_num is not None:
|
||||
chapter_bookmarks.append((pos, ch_num))
|
||||
|
||||
print(f" Chapter bookmarks: {len(chapter_bookmarks)}")
|
||||
|
||||
# Sort chapter bookmarks by position
|
||||
chapter_bookmarks.sort(key=lambda x: x[0])
|
||||
|
||||
# Build an ordered list of all section start positions for boundary computation
|
||||
all_section_starts = sorted([pos for pos, _ in chapter_bookmarks])
|
||||
|
||||
def next_section_after(pos):
|
||||
"""Return the next section start after `pos`, or gg_end."""
|
||||
for s in all_section_starts:
|
||||
if s > pos:
|
||||
return s
|
||||
return gg_end
|
||||
|
||||
# Step 4: Extract Introduction
|
||||
# The introduction is everything between the book header and the first chapter bookmark
|
||||
introduction = None
|
||||
if chapter_bookmarks:
|
||||
first_chapter_pos = chapter_bookmarks[0][0]
|
||||
# Introduction starts after the book header (gg_start + 1) and ends at first bookmark
|
||||
intro_content_start = gg_start + 1
|
||||
intro_content_end = first_chapter_pos
|
||||
intro_paras = extract_paragraphs_for_intro(
|
||||
all_paragraphs, intro_content_start, intro_content_end
|
||||
)
|
||||
if intro_paras:
|
||||
introduction = {
|
||||
"slug": "introduction",
|
||||
"title": "Introduction",
|
||||
"type": "intro-segment",
|
||||
"book": "gg",
|
||||
"order": 1,
|
||||
"paragraphs": intro_paras,
|
||||
}
|
||||
|
||||
# Step 5: Extract chapters 1–18
|
||||
chapters = []
|
||||
for idx, (ch_pos, ch_num) in enumerate(chapter_bookmarks):
|
||||
content_start = ch_pos + 1
|
||||
content_end = next_section_after(ch_pos)
|
||||
|
||||
paragraphs = extract_paragraphs_for_chapter(
|
||||
all_paragraphs, content_start, content_end
|
||||
)
|
||||
|
||||
# Get title from CHAPTER_TITLES mapping, or try to extract from p223
|
||||
title = CHAPTER_TITLES.get(ch_num, f"Chapter {ch_num}")
|
||||
|
||||
# Try to extract title from the first p223 in the range
|
||||
for i in range(content_start, min(content_start + 10, content_end)):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
if cls == CHAPTER_TITLE_CLASS:
|
||||
extracted = extract_chapter_title(p)
|
||||
if extracted:
|
||||
_, extracted_title = extracted
|
||||
if extracted_title:
|
||||
title = extracted_title
|
||||
break
|
||||
|
||||
ch = {
|
||||
"book": "gg",
|
||||
"chapter": ch_num,
|
||||
"title": title,
|
||||
"type": "chapter",
|
||||
"paragraphs": paragraphs,
|
||||
}
|
||||
chapters.append(ch)
|
||||
|
||||
# Step 6: Build intro segments list
|
||||
intro_segments = []
|
||||
if introduction:
|
||||
intro_segments.append(introduction)
|
||||
|
||||
# Step 7: Build chapter listing for intro.json
|
||||
chapter_listing = [
|
||||
{"number": ch["chapter"], "title": ch["title"]} for ch in chapters
|
||||
]
|
||||
|
||||
return {
|
||||
"intro_segments": intro_segments,
|
||||
"chapters": chapters,
|
||||
"chapter_listing": chapter_listing,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def write_json(data, filepath):
|
||||
"""Write data as formatted JSON to filepath, creating directories as needed."""
|
||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def main():
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
html_path = os.path.join(base_dir, "public", "html", "other.html")
|
||||
output_base = os.path.join(base_dir, "lib", "jsons", "books", "gg")
|
||||
|
||||
if not os.path.exists(html_path):
|
||||
print(f"ERROR: {html_path} not found!")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Reading {html_path}...")
|
||||
with open(html_path, "r", encoding="utf-8") as f:
|
||||
soup = BeautifulSoup(f, "html.parser")
|
||||
|
||||
print("Extracting Gītār Gān...\n")
|
||||
result = extract_gg(soup)
|
||||
|
||||
intro_segments = result["intro_segments"]
|
||||
chapters = result["chapters"]
|
||||
chapter_listing = result["chapter_listing"]
|
||||
|
||||
# --- Write intro.json ---
|
||||
intro_meta = {
|
||||
"book": "gg",
|
||||
"title": "Gītār Gān",
|
||||
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
|
||||
"year": 1995,
|
||||
"description": "A Bengali poetic rendering of Bhagavad-gītā As It Is by Śrīla Prabhupāda.",
|
||||
"totalChapters": 18,
|
||||
"chapters": chapter_listing,
|
||||
}
|
||||
intro_path = os.path.join(output_base, "intro.json")
|
||||
write_json(intro_meta, intro_path)
|
||||
print(f"\n Written: {intro_path}")
|
||||
|
||||
# --- Write intro segments ---
|
||||
print(f"\n--- Intro Segments ---")
|
||||
for seg in intro_segments:
|
||||
filepath = os.path.join(output_base, "intro", f"{seg['slug']}.json")
|
||||
write_json(seg, filepath)
|
||||
print(
|
||||
f" {seg['order']}. {seg['title']}: {len(seg['paragraphs'])} paragraphs → {filepath}"
|
||||
)
|
||||
|
||||
# --- Write chapters ---
|
||||
print(f"\n--- Chapters ---")
|
||||
for ch in chapters:
|
||||
ch_num_str = str(ch["chapter"]).zfill(2)
|
||||
filepath = os.path.join(output_base, "chapters", f"{ch_num_str}.json")
|
||||
write_json(ch, filepath)
|
||||
print(
|
||||
f" Ch {ch['chapter']:>2}: {len(ch['paragraphs']):>3} paragraphs — "
|
||||
f"{ch['title']}"
|
||||
)
|
||||
|
||||
# --- Summary ---
|
||||
total_intro_paras = sum(len(s["paragraphs"]) for s in intro_segments)
|
||||
total_chapter_paras = sum(len(ch["paragraphs"]) for ch in chapters)
|
||||
total_paras = total_intro_paras + total_chapter_paras
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" EXTRACTION SUMMARY")
|
||||
print(f"{'=' * 60}")
|
||||
print(f" Intro segments: {len(intro_segments)}")
|
||||
for seg in intro_segments:
|
||||
print(
|
||||
f" {seg['order']}. {seg['title']}: {len(seg['paragraphs'])} paragraphs"
|
||||
)
|
||||
print(f" Chapters: {len(chapters)}")
|
||||
print(f" Total paragraphs: {total_paras}")
|
||||
print(f" Intro: {total_intro_paras}")
|
||||
print(f" Chapters: {total_chapter_paras}")
|
||||
if chapters:
|
||||
print(f" Avg paras/chapter: {total_chapter_paras / len(chapters):.1f}")
|
||||
print(f" Output directory: {output_base}/")
|
||||
|
||||
# --- Samples ---
|
||||
print(f"\n--- Sample: Chapter 1 ---")
|
||||
if chapters:
|
||||
ch1 = chapters[0]
|
||||
for i, p in enumerate(ch1["paragraphs"][:5]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:120]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
if len(chapters) >= 9:
|
||||
print(f"\n--- Sample: Chapter 9 ---")
|
||||
ch9 = [c for c in chapters if c["chapter"] == 9]
|
||||
if ch9:
|
||||
for i, p in enumerate(ch9[0]["paragraphs"][:3]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:120]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
if len(chapters) >= 18:
|
||||
print(f"\n--- Sample: Chapter 18 (last) ---")
|
||||
last = chapters[-1]
|
||||
for i, p in enumerate(last["paragraphs"][:3]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:120]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
# --- Quality Checks ---
|
||||
print(f"\n--- Quality Checks ---")
|
||||
|
||||
# Check all 18 chapters present
|
||||
expected_chapters = set(range(1, 19))
|
||||
actual_chapters = set(ch["chapter"] for ch in chapters)
|
||||
missing = expected_chapters - actual_chapters
|
||||
extra = actual_chapters - expected_chapters
|
||||
if missing:
|
||||
print(f" ⚠ Missing chapters: {sorted(missing)}")
|
||||
if extra:
|
||||
print(f" ⚠ Extra chapters: {sorted(extra)}")
|
||||
if not missing and not extra:
|
||||
print(f" ✅ All 18 chapters present")
|
||||
|
||||
# Check intro segments
|
||||
expected_intros = {"introduction"}
|
||||
actual_intros = {s["slug"] for s in intro_segments}
|
||||
missing_intros = expected_intros - actual_intros
|
||||
if missing_intros:
|
||||
print(f" ⚠ Missing intro segments: {missing_intros}")
|
||||
else:
|
||||
print(f" ✅ Introduction segment present")
|
||||
|
||||
# Check empty chapters
|
||||
empty_chapters = [ch["chapter"] for ch in chapters if len(ch["paragraphs"]) == 0]
|
||||
if empty_chapters:
|
||||
print(f" ⚠ Empty chapters (0 paragraphs): {empty_chapters}")
|
||||
else:
|
||||
print(f" ✅ No empty chapters")
|
||||
|
||||
# Check ScaGoudy decoding — look for common encoded characters
|
||||
encoded_chars = set("äåöëòçüéàùè")
|
||||
suspect_count = 0
|
||||
all_sections = chapters + intro_segments
|
||||
for section in all_sections:
|
||||
for p in section["paragraphs"]:
|
||||
plain = re.sub(r"<[^>]+>", "", p)
|
||||
for c in encoded_chars:
|
||||
if c in plain:
|
||||
suspect_count += 1
|
||||
break
|
||||
if suspect_count == 0:
|
||||
print(f" ✅ No suspected undecoded ScaGoudy characters found")
|
||||
else:
|
||||
print(
|
||||
f" ℹ {suspect_count} paragraphs contain characters that may be undecoded ScaGoudy"
|
||||
)
|
||||
|
||||
# Check verse blocks
|
||||
verse_block_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if '<div style="text-align:center"><i>' in p:
|
||||
verse_block_count += 1
|
||||
print(f" ℹ {verse_block_count} verse blocks in chapters")
|
||||
|
||||
# Check text headings
|
||||
text_heading_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if re.match(r"<b>Texts?\s+\d+", p):
|
||||
text_heading_count += 1
|
||||
print(f" ℹ {text_heading_count} text number headings in chapters")
|
||||
|
||||
# ScaGoudy decoding spot check
|
||||
print(f"\n--- ScaGoudy Decoding Spot Check ---")
|
||||
if chapters:
|
||||
ch1_text = " ".join(chapters[0]["paragraphs"][:15])
|
||||
if "Kṛṣṇa" in ch1_text:
|
||||
print(f" ✅ 'Kṛṣṇa' correctly decoded in Chapter 1")
|
||||
elif "Kåñëa" in ch1_text:
|
||||
print(f" ⚠ 'Kåñëa' still encoded — ScaGoudy decoding may have failed!")
|
||||
else:
|
||||
print(
|
||||
f" ℹ 'Kṛṣṇa' not found in Chapter 1 first 15 paragraphs (may be normal)"
|
||||
)
|
||||
|
||||
if "Kurukṣetra" in ch1_text:
|
||||
print(f" ✅ 'Kurukṣetra' correctly decoded in Chapter 1")
|
||||
elif "Kurukñetra" in ch1_text:
|
||||
print(f" ⚠ 'Kurukñetra' still encoded")
|
||||
else:
|
||||
print(f" ℹ 'Kurukṣetra' not found in Chapter 1 first 15 paragraphs")
|
||||
|
||||
# Sample verse blocks
|
||||
print(f"\n--- Sample Verse Blocks ---")
|
||||
shown = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if '<div style="text-align:center"><i>' in p:
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:120]
|
||||
print(f" Ch {ch['chapter']}: {preview}...")
|
||||
shown += 1
|
||||
if shown >= 3:
|
||||
break
|
||||
if shown >= 3:
|
||||
break
|
||||
|
||||
# Check chapter headings
|
||||
print(f"\n--- Chapter Headings ---")
|
||||
heading_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if p.startswith("<b>Chapter"):
|
||||
heading_count += 1
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:80]
|
||||
if heading_count <= 5:
|
||||
print(f" Ch {ch['chapter']}: {preview}")
|
||||
break
|
||||
print(f" Total chapter headings: {heading_count}")
|
||||
|
||||
# Check closing statements
|
||||
print(f"\n--- Closing Statements ---")
|
||||
closing_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
plain = re.sub(r"<[^>]+>", "", p)
|
||||
if "Bhaktivedānta" in plain and "sings" in plain.lower():
|
||||
closing_count += 1
|
||||
break
|
||||
print(f" Chapters with closing statement: {closing_count} / {len(chapters)}")
|
||||
|
||||
print(f"\nDone! ✨")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,904 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract Life Comes from Life (1979) from other.html.
|
||||
|
||||
The book has 3 intro segments (foreword, introduction, about-the-author),
|
||||
and 16 chapters ("The First Morning Walk" through "The Sixteenth Morning Walk").
|
||||
|
||||
Output:
|
||||
lib/jsons/books/lcl/intro.json — Book metadata
|
||||
lib/jsons/books/lcl/intro/*.json — Intro segments
|
||||
lib/jsons/books/lcl/chapters/*.json — Chapters (01–16)
|
||||
|
||||
Usage:
|
||||
python3 scripts/lcl_extract.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ScaGoudy → IAST Unicode mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
SCAGOUDY_TO_IAST = {
|
||||
"ä": "ā",
|
||||
"Ä": "Ā",
|
||||
"å": "ṛ",
|
||||
"Å": "Ṛ",
|
||||
"ñ": "ṣ",
|
||||
"Ñ": "Ṣ",
|
||||
"ö": "ṭ",
|
||||
"Ö": "Ṭ",
|
||||
"ë": "ṇ",
|
||||
"Ë": "Ṇ",
|
||||
"ò": "ḍ",
|
||||
"Ò": "Ḍ",
|
||||
"ç": "ś",
|
||||
"Ç": "Ś",
|
||||
"ì": "ṅ",
|
||||
"ï": "ñ",
|
||||
"ü": "ū",
|
||||
"Ü": "Ū",
|
||||
"é": "ī",
|
||||
"É": "Ī",
|
||||
"à": "ṁ",
|
||||
"ù": "ḥ",
|
||||
"è": "ṝ",
|
||||
"û": "ṁl",
|
||||
"ÿ": "ḷ",
|
||||
}
|
||||
|
||||
|
||||
def decode_scagoudy(text: str) -> str:
|
||||
"""Replace ScaGoudy-encoded characters with proper IAST Unicode."""
|
||||
for src, dst in SCAGOUDY_TO_IAST.items():
|
||||
text = text.replace(src, dst)
|
||||
return text
|
||||
|
||||
|
||||
def decode_html_content(tag: Tag) -> str:
|
||||
"""Extract inner HTML from a tag, decode ScaGoudy, preserve formatting tags."""
|
||||
inner = "".join(str(child) for child in tag.children)
|
||||
inner = decode_scagoudy(inner)
|
||||
# Remove span tags with CSS classes (RTF conversion artifacts)
|
||||
inner = re.sub(
|
||||
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, flags=re.DOTALL
|
||||
)
|
||||
# Clean up Apple-tab-span leftovers (tab characters)
|
||||
inner = inner.replace("\t", " ")
|
||||
# Normalize whitespace
|
||||
inner = re.sub(r"\s+", " ", inner).strip()
|
||||
return inner
|
||||
|
||||
|
||||
def decode_text(tag: Tag) -> str:
|
||||
"""Get plain text from a tag with ScaGoudy decoding."""
|
||||
return decode_scagoudy(tag.get_text().strip())
|
||||
|
||||
|
||||
def get_css_class(tag: Tag) -> str:
|
||||
"""Get the first CSS class of a tag, or empty string."""
|
||||
classes = tag.get("class", [])
|
||||
return classes[0] if classes else ""
|
||||
|
||||
|
||||
def is_empty(tag: Tag) -> bool:
|
||||
"""Check if a tag is empty or whitespace-only."""
|
||||
text = tag.get_text().strip()
|
||||
return not text or text == "\xa0"
|
||||
|
||||
|
||||
def is_dash_line(text: str) -> bool:
|
||||
"""Return True if text is only dashes, spaces, and/or whitespace."""
|
||||
cleaned = text.strip()
|
||||
if not cleaned:
|
||||
return False
|
||||
return all(c in "-—–\u2014\u2013 \t" for c in cleaned)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSS class categorization for LCL
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Book title header class (bold, e.g. "LIFE COMES FROM LIFE (1979)")
|
||||
BOOK_HEADER_CLASS = "p166"
|
||||
|
||||
# Subtitle class
|
||||
SUBTITLE_CLASS = "p167"
|
||||
|
||||
# Bookmark classes — used for section boundary detection
|
||||
BOOKMARK_CLASS = "p13"
|
||||
BOOKMARK_CLASS_168 = "p168" # used for Foreword / Introduction bookmarks
|
||||
|
||||
# Body paragraph classes (primary content)
|
||||
BODY_CLASSES = frozenset(["p170", "p171", "p6", "p68", "p182"])
|
||||
|
||||
# Dialogue line classes — the main content classes (speakers in bold)
|
||||
DIALOGUE_CLASSES = frozenset(["p114", "p118", "p178", "p181"])
|
||||
|
||||
# Sanskrit verse pāda classes (italic, centered)
|
||||
VERSE_PADA_CLASSES = frozenset(["p5", "p180"])
|
||||
|
||||
# Verse translation classes
|
||||
TRANSLATION_CLASSES = frozenset(["p29", "p94", "p184", "p186"])
|
||||
|
||||
# Chapter heading (bold)
|
||||
CHAPTER_HEADING_CLASS = "p27"
|
||||
|
||||
# Section subheading classes (topic markers within chapters)
|
||||
SECTION_HEADING_CLASSES = frozenset(["p36", "p64"])
|
||||
|
||||
# Section subheading bookmark (contains topic title text)
|
||||
SECTION_BOOKMARK_CLASS = "p49"
|
||||
|
||||
# Foreword heading (bold)
|
||||
FOREWORD_HEADING_CLASS = "p169"
|
||||
|
||||
# Title page / founder-ācārya line
|
||||
TITLE_PAGE_CLASS = "p3"
|
||||
|
||||
# Companion description (italic, who accompanies Prabhupāda)
|
||||
COMPANION_CLASS = "p177"
|
||||
|
||||
# Block quote classes
|
||||
BLOCKQUOTE_CLASSES = frozenset(["p173", "p175"])
|
||||
|
||||
# Attribution classes
|
||||
ATTRIBUTION_CLASSES = frozenset(["p174", "p176", "p42"])
|
||||
|
||||
# Reference / citation line
|
||||
REFERENCE_CLASS = "p187"
|
||||
|
||||
# Classes to skip entirely (empty/decorative/break)
|
||||
SKIP_CLASSES = frozenset(["p15", "p172", "p179", "p183", "p135"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ordinal mapping for chapter names
|
||||
# ---------------------------------------------------------------------------
|
||||
ORDINAL_MAP = {
|
||||
"First": 1,
|
||||
"Second": 2,
|
||||
"Third": 3,
|
||||
"Fourth": 4,
|
||||
"Fifth": 5,
|
||||
"Sixth": 6,
|
||||
"Seventh": 7,
|
||||
"Eighth": 8,
|
||||
"Ninth": 9,
|
||||
"Tenth": 10,
|
||||
"Eleventh": 11,
|
||||
"Twelfth": 12,
|
||||
"Thirteenth": 13,
|
||||
"Fourteenth": 14,
|
||||
"Fifteenth": 15,
|
||||
"Sixteenth": 16,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extraction helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def find_lcl_boundaries(all_paragraphs: list) -> tuple:
|
||||
"""Find the start and end paragraph indices of the LCL content.
|
||||
|
||||
Start: <p class="p166"> containing "LIFE COMES FROM LIFE"
|
||||
End: <p class="p13"> containing "LCL: The Challenge"
|
||||
"""
|
||||
start_idx = None
|
||||
end_idx = len(all_paragraphs)
|
||||
|
||||
for i, p in enumerate(all_paragraphs):
|
||||
cls = get_css_class(p)
|
||||
text = decode_text(p)
|
||||
|
||||
# Find start
|
||||
if start_idx is None:
|
||||
if cls == BOOK_HEADER_CLASS and "LIFE COMES FROM LIFE" in text.upper():
|
||||
start_idx = i
|
||||
continue
|
||||
|
||||
# Find end: the "LCL: The Challenge" bookmark
|
||||
if cls == BOOKMARK_CLASS:
|
||||
if "LCL:" in text and "The Challenge" in text:
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
if start_idx is None:
|
||||
print("ERROR: Could not find 'LIFE COMES FROM LIFE' header in other.html!")
|
||||
sys.exit(1)
|
||||
|
||||
return start_idx, end_idx
|
||||
|
||||
|
||||
def find_all_bookmarks(all_paragraphs: list, start_idx: int, end_idx: int) -> list:
|
||||
"""Find all LCL bookmark tags in the LCL range.
|
||||
|
||||
Bookmarks can be either p13 or p168 class with text containing "LCL:".
|
||||
Returns list of (paragraph_index, bookmark_text).
|
||||
"""
|
||||
bookmarks = []
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
if cls not in (BOOKMARK_CLASS, BOOKMARK_CLASS_168):
|
||||
continue
|
||||
# Get text, stripping span noise
|
||||
text = decode_text(p).strip()
|
||||
if text.startswith("LCL:"):
|
||||
bookmarks.append((i, text))
|
||||
return bookmarks
|
||||
|
||||
|
||||
def parse_chapter_bookmark(text: str):
|
||||
"""Parse a chapter bookmark like 'LCL: The First Morning Walk'.
|
||||
|
||||
Returns (chapter_number, title) or None if not a chapter bookmark.
|
||||
"""
|
||||
m = re.match(r"LCL:\s+The\s+(\w+)\s+Morning\s+Walk", text.strip())
|
||||
if m:
|
||||
ordinal = m.group(1)
|
||||
num = ORDINAL_MAP.get(ordinal)
|
||||
if num is not None:
|
||||
# Reconstruct the title from the matched text
|
||||
title = f"The {ordinal} Morning Walk"
|
||||
return num, title
|
||||
return None
|
||||
|
||||
|
||||
def parse_intro_bookmark(text: str):
|
||||
"""Check if this is a Foreword or Introduction bookmark.
|
||||
|
||||
Returns the slug ("foreword" or "introduction") or None.
|
||||
"""
|
||||
stripped = text.strip()
|
||||
if stripped == "LCL: Foreword":
|
||||
return "foreword"
|
||||
if stripped == "LCL: Introduction":
|
||||
return "introduction"
|
||||
return None
|
||||
|
||||
|
||||
def parse_about_author_bookmark(text: str) -> bool:
|
||||
"""Check if this is the About the Author bookmark."""
|
||||
return "His Divine Grace" in text and text.strip().startswith("LCL:")
|
||||
|
||||
|
||||
def extract_paragraphs(
|
||||
all_paragraphs: list,
|
||||
start_idx: int,
|
||||
end_idx: int,
|
||||
) -> list:
|
||||
"""Extract paragraphs for a section (chapter, intro segment).
|
||||
|
||||
Handles all CSS classes within the LCL content.
|
||||
Groups consecutive p5/p180 verse pādas into centered italic divs.
|
||||
Detects verse patterns in dialogue/body (starts with <i>, has <br>).
|
||||
Deduplicates p49 + p36 pairs (only keeps the p36 bold version).
|
||||
"""
|
||||
result = []
|
||||
verse_accumulator = []
|
||||
|
||||
# Track the last p49 topic text so we can skip it if p36 duplicates it
|
||||
last_p49_text = None
|
||||
|
||||
def flush_verse():
|
||||
if verse_accumulator:
|
||||
joined = "<br>".join(verse_accumulator)
|
||||
result.append(f'<div style="text-align:center"><i>{joined}</i></div>')
|
||||
verse_accumulator.clear()
|
||||
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
|
||||
# Skip empty paragraphs
|
||||
if is_empty(p):
|
||||
continue
|
||||
|
||||
# Skip noise classes
|
||||
if cls in SKIP_CLASSES:
|
||||
continue
|
||||
|
||||
# Skip bookmark tags (p13, p168)
|
||||
if cls in (BOOKMARK_CLASS, BOOKMARK_CLASS_168):
|
||||
continue
|
||||
|
||||
# Skip book title header (p166)
|
||||
if cls == BOOK_HEADER_CLASS:
|
||||
continue
|
||||
|
||||
# Skip subtitle (p167)
|
||||
if cls == SUBTITLE_CLASS:
|
||||
continue
|
||||
|
||||
# Check for dash lines — skip them
|
||||
text = decode_text(p)
|
||||
if is_dash_line(text):
|
||||
continue
|
||||
|
||||
# Sanskrit verse pādas (p5, p180) — accumulate into centered block
|
||||
if cls in VERSE_PADA_CLASSES:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
# Remove inner <i> tags before re-wrapping
|
||||
stripped = re.sub(r"</?i>", "", html)
|
||||
verse_accumulator.append(stripped)
|
||||
continue
|
||||
|
||||
# Non-verse paragraph — flush any accumulated verse first
|
||||
flush_verse()
|
||||
|
||||
# Section subheading bookmark (p49) — topic title
|
||||
# Only include if NOT immediately followed by a p36 with the same text
|
||||
if cls == SECTION_BOOKMARK_CLASS:
|
||||
p49_text = text.strip()
|
||||
# Look ahead to see if the next non-empty paragraph is p36 with same content
|
||||
has_p36_duplicate = False
|
||||
for j in range(i + 1, min(i + 4, end_idx)):
|
||||
next_p = all_paragraphs[j]
|
||||
next_cls = get_css_class(next_p)
|
||||
if is_empty(next_p) or next_cls in SKIP_CLASSES:
|
||||
continue
|
||||
if next_cls == "p36":
|
||||
next_text = decode_text(next_p).strip()
|
||||
if next_text == p49_text:
|
||||
has_p36_duplicate = True
|
||||
break
|
||||
if has_p36_duplicate:
|
||||
# Skip p49; the p36 version will be included
|
||||
last_p49_text = p49_text
|
||||
continue
|
||||
else:
|
||||
# No p36 duplicate — include p49 as bold subheading
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
# Wrap in bold if not already
|
||||
if not html.strip().startswith("<b>"):
|
||||
html = f"<b>{html}</b>"
|
||||
result.append(html)
|
||||
last_p49_text = None
|
||||
continue
|
||||
|
||||
# Section subheading (p36) — bold topic title
|
||||
if cls == "p36":
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
# Ensure it's bold
|
||||
if not html.strip().startswith("<b>"):
|
||||
html = f"<b>{html}</b>"
|
||||
result.append(html)
|
||||
last_p49_text = None
|
||||
continue
|
||||
|
||||
# Section heading variant (p64) — topic title (not bold in HTML)
|
||||
if cls == "p64":
|
||||
# p64 is typically followed by p36 with the same text;
|
||||
# if so, skip p64 and let p36 handle it
|
||||
p64_text = text.strip()
|
||||
has_p36_duplicate = False
|
||||
for j in range(i + 1, min(i + 4, end_idx)):
|
||||
next_p = all_paragraphs[j]
|
||||
next_cls = get_css_class(next_p)
|
||||
if is_empty(next_p) or next_cls in SKIP_CLASSES:
|
||||
continue
|
||||
if next_cls == "p36":
|
||||
next_text = decode_text(next_p).strip()
|
||||
if next_text == p64_text:
|
||||
has_p36_duplicate = True
|
||||
break
|
||||
if has_p36_duplicate:
|
||||
# Skip p64; the p36 version will be included
|
||||
continue
|
||||
else:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
if not html.strip().startswith("<b>"):
|
||||
html = f"<b>{html}</b>"
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Dialogue lines (p114, p118, p178, p181) — the main content classes
|
||||
if cls in DIALOGUE_CLASSES:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
# Detect verses disguised as dialogue: starts with <i> and has <br>
|
||||
stripped_html = html.strip()
|
||||
if stripped_html.startswith("<i>") and "<br" in stripped_html:
|
||||
inner = re.sub(r"</?i>", "", stripped_html)
|
||||
result.append(
|
||||
f'<div style="text-align:center"><i>{inner}</i></div>'
|
||||
)
|
||||
else:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Chapter heading (p27) — include as paragraph
|
||||
if cls == CHAPTER_HEADING_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Foreword heading (p169) — include
|
||||
if cls == FOREWORD_HEADING_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Title page / founder-ācārya line (p3) — include
|
||||
if cls == TITLE_PAGE_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Companion description (p177) — italic description
|
||||
if cls == COMPANION_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Block quotes (p173, p175)
|
||||
if cls in BLOCKQUOTE_CLASSES:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Attribution text (p174, p176, p42)
|
||||
if cls in ATTRIBUTION_CLASSES:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Verse translations (p29, p94, p184, p186)
|
||||
if cls in TRANSLATION_CLASSES:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Reference / citation line (p187)
|
||||
if cls == REFERENCE_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Body paragraphs (p170, p171, p6, p68, p182)
|
||||
if cls in BODY_CLASSES:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
# Detect Sanskrit verses disguised as body paragraphs:
|
||||
# they start with <i> and contain <br> (multi-line italic = verse)
|
||||
stripped_html = html.strip()
|
||||
if stripped_html.startswith("<i>") and "<br" in stripped_html:
|
||||
inner = re.sub(r"</?i>", "", stripped_html)
|
||||
result.append(
|
||||
f'<div style="text-align:center"><i>{inner}</i></div>'
|
||||
)
|
||||
else:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Any other class — try to extract content (be permissive)
|
||||
html = decode_html_content(p)
|
||||
if html and not is_dash_line(text):
|
||||
result.append(html)
|
||||
|
||||
# Flush any trailing verse
|
||||
flush_verse()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main extraction logic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_lcl(soup: BeautifulSoup) -> dict:
|
||||
"""Extract the entire Life Comes from Life from the parsed HTML.
|
||||
|
||||
Returns a dict with:
|
||||
"intro_segments": list of intro segment dicts
|
||||
"chapters": list of chapter dicts
|
||||
"chapter_listing": list of {number, title} for intro.json
|
||||
"""
|
||||
all_paragraphs = list(soup.find_all("p"))
|
||||
|
||||
# Step 1: Find LCL boundaries
|
||||
lcl_start, lcl_end = find_lcl_boundaries(all_paragraphs)
|
||||
print(f" LCL content: paragraphs {lcl_start} to {lcl_end}")
|
||||
print(f" Total paragraphs in range: {lcl_end - lcl_start}")
|
||||
|
||||
# Step 2: Find all bookmarks
|
||||
bookmarks = find_all_bookmarks(all_paragraphs, lcl_start, lcl_end)
|
||||
print(f" Found {len(bookmarks)} LCL bookmarks")
|
||||
for pos, text in bookmarks:
|
||||
print(f" Para ~{pos}: {text}")
|
||||
|
||||
# Step 3: Categorize bookmarks
|
||||
foreword_bookmark = None # paragraph index
|
||||
introduction_bookmark = None
|
||||
about_author_bookmark = None
|
||||
chapter_bookmarks = [] # (para_idx, chapter_num, title)
|
||||
|
||||
for pos, text in bookmarks:
|
||||
ch = parse_chapter_bookmark(text)
|
||||
if ch:
|
||||
chapter_bookmarks.append((pos, ch[0], ch[1]))
|
||||
continue
|
||||
intro_slug = parse_intro_bookmark(text)
|
||||
if intro_slug == "foreword":
|
||||
foreword_bookmark = pos
|
||||
elif intro_slug == "introduction":
|
||||
introduction_bookmark = pos
|
||||
elif parse_about_author_bookmark(text):
|
||||
about_author_bookmark = pos
|
||||
|
||||
print(
|
||||
f" Foreword bookmark: {'found' if foreword_bookmark is not None else 'NOT FOUND'}"
|
||||
)
|
||||
print(
|
||||
f" Introduction bookmark: {'found' if introduction_bookmark is not None else 'NOT FOUND'}"
|
||||
)
|
||||
print(f" Chapter bookmarks: {len(chapter_bookmarks)}")
|
||||
print(
|
||||
f" About the Author bookmark: {'found' if about_author_bookmark is not None else 'NOT FOUND'}"
|
||||
)
|
||||
|
||||
# Build an ordered list of all section start positions for boundary computation
|
||||
all_section_starts = sorted(
|
||||
([foreword_bookmark] if foreword_bookmark is not None else [])
|
||||
+ ([introduction_bookmark] if introduction_bookmark is not None else [])
|
||||
+ [pos for pos, _, _ in chapter_bookmarks]
|
||||
+ ([about_author_bookmark] if about_author_bookmark is not None else [])
|
||||
)
|
||||
|
||||
def next_section_after(pos):
|
||||
"""Return the next section start after `pos`, or lcl_end."""
|
||||
for s in all_section_starts:
|
||||
if s > pos:
|
||||
return s
|
||||
return lcl_end
|
||||
|
||||
# Step 4: Extract Foreword
|
||||
foreword = None
|
||||
if foreword_bookmark is not None:
|
||||
fw_content_start = foreword_bookmark + 1
|
||||
fw_content_end = next_section_after(foreword_bookmark)
|
||||
fw_paras = extract_paragraphs(all_paragraphs, fw_content_start, fw_content_end)
|
||||
foreword = {
|
||||
"slug": "foreword",
|
||||
"title": "Foreword",
|
||||
"type": "intro-segment",
|
||||
"book": "lcl",
|
||||
"order": 1,
|
||||
"paragraphs": fw_paras,
|
||||
}
|
||||
|
||||
# Step 5: Extract Introduction
|
||||
introduction = None
|
||||
if introduction_bookmark is not None:
|
||||
intro_content_start = introduction_bookmark + 1
|
||||
intro_content_end = next_section_after(introduction_bookmark)
|
||||
intro_paras = extract_paragraphs(
|
||||
all_paragraphs, intro_content_start, intro_content_end
|
||||
)
|
||||
introduction = {
|
||||
"slug": "introduction",
|
||||
"title": "Introduction",
|
||||
"type": "intro-segment",
|
||||
"book": "lcl",
|
||||
"order": 2,
|
||||
"paragraphs": intro_paras,
|
||||
}
|
||||
|
||||
# Step 6: Extract chapters 1–16
|
||||
chapters = []
|
||||
for idx, (ch_pos, ch_num, ch_title) in enumerate(chapter_bookmarks):
|
||||
content_start = ch_pos + 1
|
||||
content_end = next_section_after(ch_pos)
|
||||
|
||||
paragraphs = extract_paragraphs(all_paragraphs, content_start, content_end)
|
||||
|
||||
ch = {
|
||||
"book": "lcl",
|
||||
"chapter": ch_num,
|
||||
"title": ch_title,
|
||||
"type": "chapter",
|
||||
"paragraphs": paragraphs,
|
||||
}
|
||||
chapters.append(ch)
|
||||
|
||||
# Step 7: Extract About the Author
|
||||
about_the_author = None
|
||||
if about_author_bookmark is not None:
|
||||
ata_content_start = about_author_bookmark + 1
|
||||
ata_content_end = lcl_end
|
||||
ata_paras = extract_paragraphs(
|
||||
all_paragraphs, ata_content_start, ata_content_end
|
||||
)
|
||||
about_the_author = {
|
||||
"slug": "about-the-author",
|
||||
"title": "About the Author",
|
||||
"type": "intro-segment",
|
||||
"book": "lcl",
|
||||
"order": 99,
|
||||
"paragraphs": ata_paras,
|
||||
}
|
||||
|
||||
# Step 8: Build intro segments list
|
||||
intro_segments = []
|
||||
if foreword:
|
||||
intro_segments.append(foreword)
|
||||
if introduction:
|
||||
intro_segments.append(introduction)
|
||||
if about_the_author:
|
||||
intro_segments.append(about_the_author)
|
||||
|
||||
# Step 9: Build chapter listing for intro.json
|
||||
chapter_listing = [
|
||||
{"number": ch["chapter"], "title": ch["title"]} for ch in chapters
|
||||
]
|
||||
|
||||
return {
|
||||
"intro_segments": intro_segments,
|
||||
"chapters": chapters,
|
||||
"chapter_listing": chapter_listing,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def write_json(data, filepath):
|
||||
"""Write data as formatted JSON to filepath, creating directories as needed."""
|
||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def main():
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
html_path = os.path.join(base_dir, "public", "html", "other.html")
|
||||
output_base = os.path.join(base_dir, "lib", "jsons", "books", "lcl")
|
||||
|
||||
if not os.path.exists(html_path):
|
||||
print(f"ERROR: {html_path} not found!")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Reading {html_path}...")
|
||||
with open(html_path, "r", encoding="utf-8") as f:
|
||||
soup = BeautifulSoup(f, "html.parser")
|
||||
|
||||
print("Extracting Life Comes from Life (1979)...\n")
|
||||
result = extract_lcl(soup)
|
||||
|
||||
intro_segments = result["intro_segments"]
|
||||
chapters = result["chapters"]
|
||||
chapter_listing = result["chapter_listing"]
|
||||
|
||||
# --- Write intro.json ---
|
||||
intro_meta = {
|
||||
"book": "lcl",
|
||||
"title": "Life Comes from Life",
|
||||
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
|
||||
"year": 1979,
|
||||
"description": "Morning walks with Śrīla Prabhupāda — a critique of modern science's theories on the origin of life.",
|
||||
"totalChapters": 16,
|
||||
"chapters": chapter_listing,
|
||||
}
|
||||
intro_path = os.path.join(output_base, "intro.json")
|
||||
write_json(intro_meta, intro_path)
|
||||
print(f"\n Written: {intro_path}")
|
||||
|
||||
# --- Write intro segments ---
|
||||
print(f"\n--- Intro Segments ---")
|
||||
for seg in intro_segments:
|
||||
filepath = os.path.join(output_base, "intro", f"{seg['slug']}.json")
|
||||
write_json(seg, filepath)
|
||||
print(
|
||||
f" {seg['order']}. {seg['title']}: {len(seg['paragraphs'])} paragraphs → {filepath}"
|
||||
)
|
||||
|
||||
# --- Write chapters ---
|
||||
print(f"\n--- Chapters ---")
|
||||
for ch in chapters:
|
||||
ch_num_str = str(ch["chapter"]).zfill(2)
|
||||
filepath = os.path.join(output_base, "chapters", f"{ch_num_str}.json")
|
||||
write_json(ch, filepath)
|
||||
print(
|
||||
f" Ch {ch['chapter']:>2}: {len(ch['paragraphs']):>3} paragraphs — "
|
||||
f"{ch['title']}"
|
||||
)
|
||||
|
||||
# --- Summary ---
|
||||
total_intro_paras = sum(len(s["paragraphs"]) for s in intro_segments)
|
||||
total_chapter_paras = sum(len(ch["paragraphs"]) for ch in chapters)
|
||||
total_paras = total_intro_paras + total_chapter_paras
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" EXTRACTION SUMMARY")
|
||||
print(f"{'=' * 60}")
|
||||
print(f" Intro segments: {len(intro_segments)}")
|
||||
for seg in intro_segments:
|
||||
print(
|
||||
f" {seg['order']}. {seg['title']}: {len(seg['paragraphs'])} paragraphs"
|
||||
)
|
||||
print(f" Chapters: {len(chapters)}")
|
||||
print(f" Total paragraphs: {total_paras}")
|
||||
print(f" Intro: {total_intro_paras}")
|
||||
print(f" Chapters: {total_chapter_paras}")
|
||||
if chapters:
|
||||
print(f" Avg paras/chapter: {total_chapter_paras / len(chapters):.1f}")
|
||||
print(f" Output directory: {output_base}/")
|
||||
|
||||
# --- Samples ---
|
||||
print(f"\n--- Sample: Chapter 1 ---")
|
||||
if chapters:
|
||||
ch1 = chapters[0]
|
||||
for i, p in enumerate(ch1["paragraphs"][:5]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:120]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
if len(chapters) >= 8:
|
||||
print(f"\n--- Sample: Chapter 8 ---")
|
||||
ch8 = [c for c in chapters if c["chapter"] == 8]
|
||||
if ch8:
|
||||
for i, p in enumerate(ch8[0]["paragraphs"][:3]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:120]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
if len(chapters) >= 16:
|
||||
print(f"\n--- Sample: Chapter 16 (last) ---")
|
||||
last = chapters[-1]
|
||||
for i, p in enumerate(last["paragraphs"][:3]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:120]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
# --- Quality Checks ---
|
||||
print(f"\n--- Quality Checks ---")
|
||||
|
||||
# Check all 16 chapters present
|
||||
expected_chapters = set(range(1, 17))
|
||||
actual_chapters = set(ch["chapter"] for ch in chapters)
|
||||
missing = expected_chapters - actual_chapters
|
||||
extra = actual_chapters - expected_chapters
|
||||
if missing:
|
||||
print(f" ⚠ Missing chapters: {sorted(missing)}")
|
||||
if extra:
|
||||
print(f" ⚠ Extra chapters: {sorted(extra)}")
|
||||
if not missing and not extra:
|
||||
print(f" ✅ All 16 chapters present")
|
||||
|
||||
# Check intro segments
|
||||
expected_intros = {"foreword", "introduction", "about-the-author"}
|
||||
actual_intros = {s["slug"] for s in intro_segments}
|
||||
missing_intros = expected_intros - actual_intros
|
||||
if missing_intros:
|
||||
print(f" ⚠ Missing intro segments: {missing_intros}")
|
||||
else:
|
||||
print(f" ✅ All 3 intro segments present")
|
||||
|
||||
# Check empty chapters
|
||||
empty_chapters = [ch["chapter"] for ch in chapters if len(ch["paragraphs"]) == 0]
|
||||
if empty_chapters:
|
||||
print(f" ⚠ Empty chapters (0 paragraphs): {empty_chapters}")
|
||||
else:
|
||||
print(f" ✅ No empty chapters")
|
||||
|
||||
# Check ScaGoudy decoding — look for common encoded characters
|
||||
encoded_chars = set("äåöëòçüéàùè")
|
||||
suspect_count = 0
|
||||
all_sections = chapters + intro_segments
|
||||
for section in all_sections:
|
||||
for p in section["paragraphs"]:
|
||||
plain = re.sub(r"<[^>]+>", "", p)
|
||||
for c in encoded_chars:
|
||||
if c in plain:
|
||||
suspect_count += 1
|
||||
break
|
||||
if suspect_count == 0:
|
||||
print(f" ✅ No suspected undecoded ScaGoudy characters found")
|
||||
else:
|
||||
print(
|
||||
f" ℹ {suspect_count} paragraphs contain characters that may be undecoded ScaGoudy"
|
||||
)
|
||||
|
||||
# Check verse blocks
|
||||
verse_block_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if '<div style="text-align:center"><i>' in p:
|
||||
verse_block_count += 1
|
||||
print(f" ℹ {verse_block_count} verse blocks in chapters")
|
||||
|
||||
# ScaGoudy decoding spot check
|
||||
print(f"\n--- ScaGoudy Decoding Spot Check ---")
|
||||
if chapters:
|
||||
ch1_text = " ".join(chapters[0]["paragraphs"][:15])
|
||||
if "Prabhupāda" in ch1_text:
|
||||
print(f" ✅ 'Prabhupāda' correctly decoded")
|
||||
elif "Prabhupäda" in ch1_text:
|
||||
print(f" ⚠ ScaGoudy decoding incomplete for 'Prabhupäda'")
|
||||
else:
|
||||
print(f" ℹ 'Prabhupāda' not found in Chapter 1 first 15 paragraphs")
|
||||
|
||||
if "Kṛṣṇa" in ch1_text:
|
||||
print(f" ✅ 'Kṛṣṇa' correctly decoded in Chapter 1")
|
||||
elif "Kåñëa" in ch1_text:
|
||||
print(f" ⚠ 'Kåñëa' still encoded — ScaGoudy decoding may have failed!")
|
||||
else:
|
||||
print(
|
||||
f" ℹ 'Kṛṣṇa' not found in Chapter 1 first 15 paragraphs (may be normal)"
|
||||
)
|
||||
|
||||
# Check for Vedas
|
||||
if "Vedas" in ch1_text or "Vedic" in ch1_text:
|
||||
print(f" ✅ 'Vedas'/'Vedic' found in Chapter 1")
|
||||
|
||||
# Dialogue speaker check
|
||||
print(f"\n--- Dialogue Speaker Check ---")
|
||||
speaker_counts = {}
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
# Look for bold speaker patterns: <b>SPEAKER.</b> or <b>SPEAKER:</b>
|
||||
m = re.match(r"<b>([^<]+?)[.:]</b>", p)
|
||||
if m:
|
||||
speaker = m.group(1).strip()
|
||||
speaker_counts[speaker] = speaker_counts.get(speaker, 0) + 1
|
||||
if speaker_counts:
|
||||
print(f" Speakers found:")
|
||||
for speaker, count in sorted(speaker_counts.items(), key=lambda x: -x[1]):
|
||||
print(f" {speaker}: {count} lines")
|
||||
else:
|
||||
print(f" ⚠ No dialogue speakers detected")
|
||||
|
||||
# Sample verse blocks
|
||||
print(f"\n--- Sample Verse Blocks ---")
|
||||
shown = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if '<div style="text-align:center"><i>' in p:
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:120]
|
||||
print(f" Ch {ch['chapter']}: {preview}...")
|
||||
shown += 1
|
||||
if shown >= 3:
|
||||
break
|
||||
if shown >= 3:
|
||||
break
|
||||
|
||||
# Check section subheadings (topic titles)
|
||||
print(f"\n--- Section Subheadings in Chapters ---")
|
||||
subheading_count = 0
|
||||
sample_subheadings = []
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
plain = re.sub(r"<[^>]+>", "", p).strip()
|
||||
# A bold-only paragraph that's short is likely a subheading
|
||||
if p.startswith("<b>") and p.endswith("</b>") and len(plain) < 80:
|
||||
subheading_count += 1
|
||||
if len(sample_subheadings) < 5:
|
||||
sample_subheadings.append(f" Ch {ch['chapter']}: {plain}")
|
||||
print(f" Total subheadings: {subheading_count}")
|
||||
for s in sample_subheadings:
|
||||
print(s)
|
||||
|
||||
print(f"\nDone! ✨")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,704 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract Light of the Bhāgavata (1983) from other.html.
|
||||
|
||||
The book has 4 intro segments (front-pages, preface, acknowledgement, the-artist)
|
||||
and 48 numbered texts, each with a verse translation and commentary paragraphs.
|
||||
|
||||
Output:
|
||||
lib/jsons/books/lob/intro.json — Book metadata
|
||||
lib/jsons/books/lob/intro/*.json — Intro segments
|
||||
lib/jsons/books/lob/chapters/*.json — 48 texts
|
||||
|
||||
Usage:
|
||||
python3 scripts/lob_extract.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ScaGoudy → IAST Unicode mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
SCAGOUDY_TO_IAST = {
|
||||
"ä": "ā",
|
||||
"Ä": "Ā",
|
||||
"å": "ṛ",
|
||||
"Å": "Ṛ",
|
||||
"ñ": "ṣ",
|
||||
"Ñ": "Ṣ",
|
||||
"ö": "ṭ",
|
||||
"Ö": "Ṭ",
|
||||
"ë": "ṇ",
|
||||
"Ë": "Ṇ",
|
||||
"ò": "ḍ",
|
||||
"Ò": "Ḍ",
|
||||
"ç": "ś",
|
||||
"Ç": "Ś",
|
||||
"ì": "ṅ",
|
||||
"ï": "ñ",
|
||||
"ü": "ū",
|
||||
"Ü": "Ū",
|
||||
"é": "ī",
|
||||
"É": "Ī",
|
||||
"à": "ṁ",
|
||||
"ù": "ḥ",
|
||||
"è": "ṝ",
|
||||
"û": "ṁl",
|
||||
"ÿ": "ḷ",
|
||||
}
|
||||
|
||||
|
||||
def decode_scagoudy(text: str) -> str:
|
||||
"""Replace ScaGoudy-encoded characters with proper IAST Unicode."""
|
||||
for src, dst in SCAGOUDY_TO_IAST.items():
|
||||
text = text.replace(src, dst)
|
||||
return text
|
||||
|
||||
|
||||
def decode_html_content(tag: Tag) -> str:
|
||||
"""Extract inner HTML from a tag, decode ScaGoudy, preserve formatting tags."""
|
||||
inner = "".join(str(child) for child in tag.children)
|
||||
inner = decode_scagoudy(inner)
|
||||
# Remove span tags with CSS classes (RTF conversion artifacts)
|
||||
inner = re.sub(
|
||||
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, flags=re.DOTALL
|
||||
)
|
||||
# Clean up Apple-tab-span leftovers (tab characters)
|
||||
inner = inner.replace("\t", " ")
|
||||
# Normalize whitespace
|
||||
inner = re.sub(r"\s+", " ", inner).strip()
|
||||
return inner
|
||||
|
||||
|
||||
def decode_text(tag: Tag) -> str:
|
||||
"""Get plain text from a tag with ScaGoudy decoding."""
|
||||
return decode_scagoudy(tag.get_text().strip())
|
||||
|
||||
|
||||
def get_css_class(tag: Tag) -> str:
|
||||
"""Get the first CSS class of a tag, or empty string."""
|
||||
classes = tag.get("class", [])
|
||||
return classes[0] if classes else ""
|
||||
|
||||
|
||||
def is_empty(tag: Tag) -> bool:
|
||||
"""Check if a tag is empty or whitespace-only."""
|
||||
text = tag.get_text().strip()
|
||||
return not text or text == "\xa0"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSS class categorization for LoB
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Book title header class
|
||||
BOOK_HEADER_CLASS = "p12"
|
||||
|
||||
# Bookmark class — used for section boundary detection
|
||||
BOOKMARK_CLASS = "p49"
|
||||
|
||||
# Body paragraph classes (primary content)
|
||||
BODY_CLASS = "p198"
|
||||
|
||||
# First commentary paragraph (slightly different styling, same role as p198)
|
||||
FIRST_COMMENTARY_CLASS = "p201"
|
||||
|
||||
# Verse translation inside <table> elements
|
||||
VERSE_TRANSLATION_CLASS = "p200"
|
||||
|
||||
# Text number heading (bold, e.g. <b>1</b>)
|
||||
TEXT_NUMBER_CLASS = "p199"
|
||||
|
||||
# Section heading (bold, e.g. "Preface", "Acknowledgement")
|
||||
SECTION_HEADING_CLASS = "p27"
|
||||
|
||||
# Sanskrit verse pādas (italic)
|
||||
VERSE_PADA_CLASS = "p5"
|
||||
|
||||
# Front pages text
|
||||
FRONT_PAGES_CLASS = "p196"
|
||||
|
||||
# Verse reference text (e.g. [Bg. 15.17])
|
||||
VERSE_REF_CLASS = "p203"
|
||||
|
||||
# Classes to skip entirely (empty/decorative/breaks)
|
||||
SKIP_CLASSES = frozenset(["p195", "p197", "p15", "p202"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bookmark parsers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def parse_text_bookmark(text: str) -> int | None:
|
||||
"""Parse a text bookmark like 'LoB:1' or 'LoB:48'.
|
||||
Returns text number (1-48) or None.
|
||||
"""
|
||||
text = text.strip()
|
||||
m = re.match(r"LoB:(\d+)", text)
|
||||
if m:
|
||||
return int(m.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def parse_intro_bookmark(text: str) -> str | None:
|
||||
"""Parse intro section bookmarks. Returns slug or None."""
|
||||
text = text.strip()
|
||||
if text == "LoB: Preface":
|
||||
return "preface"
|
||||
if text == "LoB: Acknowledgement":
|
||||
return "acknowledgement"
|
||||
if text == "LoB: The Artist":
|
||||
return "the-artist"
|
||||
return None
|
||||
|
||||
|
||||
INTRO_SLUG_TO_TITLE = {
|
||||
"preface": "Preface",
|
||||
"acknowledgement": "Acknowledgement",
|
||||
"the-artist": "The Artist",
|
||||
}
|
||||
|
||||
INTRO_SLUG_TO_ORDER = {
|
||||
"front-pages": 1,
|
||||
"preface": 2,
|
||||
"acknowledgement": 3,
|
||||
"the-artist": 4,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Boundary detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def find_lob_boundaries(all_paragraphs: list) -> tuple:
|
||||
"""Find the start and end paragraph indices of the LoB content.
|
||||
|
||||
Start: <p class="p12"> containing "Light of the Bhägawata"
|
||||
End: next <p class="p12"> after start
|
||||
"""
|
||||
start_idx = None
|
||||
end_idx = len(all_paragraphs)
|
||||
|
||||
for i, p in enumerate(all_paragraphs):
|
||||
cls = get_css_class(p)
|
||||
if cls != BOOK_HEADER_CLASS:
|
||||
continue
|
||||
text = decode_text(p)
|
||||
if "Light of the Bh" in text and "gawata" in text:
|
||||
start_idx = i
|
||||
elif start_idx is not None:
|
||||
# Next p12 after LoB start = end of LoB
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
if start_idx is None:
|
||||
print("ERROR: Could not find 'Light of the Bhāgawata' header in other.html!")
|
||||
sys.exit(1)
|
||||
|
||||
return start_idx, end_idx
|
||||
|
||||
|
||||
def find_all_bookmarks(all_paragraphs: list, start_idx: int, end_idx: int) -> list:
|
||||
"""Find all p49 bookmark tags in the LoB range.
|
||||
|
||||
Returns list of (paragraph_index, bookmark_text).
|
||||
"""
|
||||
bookmarks = []
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
if cls == BOOKMARK_CLASS:
|
||||
text = decode_text(p).strip()
|
||||
if text.startswith("LoB"):
|
||||
bookmarks.append((i, text))
|
||||
return bookmarks
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paragraph extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_paragraphs(
|
||||
all_paragraphs: list,
|
||||
start_idx: int,
|
||||
end_idx: int,
|
||||
is_front_pages: bool = False,
|
||||
is_numbered_text: bool = False,
|
||||
) -> list:
|
||||
"""Extract paragraphs for a section.
|
||||
|
||||
Handles all CSS classes within the LoB content.
|
||||
Groups consecutive p5 verse pādas into centered italic divs.
|
||||
Handles p200 verse translation paragraphs (from inside tables).
|
||||
"""
|
||||
result = []
|
||||
verse_accumulator = []
|
||||
|
||||
def flush_verse():
|
||||
if verse_accumulator:
|
||||
joined = "<br>".join(verse_accumulator)
|
||||
result.append(f'<div style="text-align:center"><i>{joined}</i></div>')
|
||||
verse_accumulator.clear()
|
||||
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
|
||||
# Skip empty paragraphs
|
||||
if is_empty(p):
|
||||
continue
|
||||
|
||||
# Skip noise classes
|
||||
if cls in SKIP_CLASSES:
|
||||
continue
|
||||
|
||||
# Skip bookmark tags (p49)
|
||||
if cls == BOOKMARK_CLASS:
|
||||
continue
|
||||
|
||||
# Skip book title header (p12)
|
||||
if cls == BOOK_HEADER_CLASS:
|
||||
continue
|
||||
|
||||
# Skip text number headings (p199) — we already know the text number
|
||||
if cls == TEXT_NUMBER_CLASS:
|
||||
continue
|
||||
|
||||
# Sanskrit verse pādas (p5) — accumulate into centered block
|
||||
if cls == VERSE_PADA_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
# Remove inner <i> tags before re-wrapping
|
||||
stripped = re.sub(r"</?i>", "", html)
|
||||
verse_accumulator.append(stripped)
|
||||
continue
|
||||
|
||||
# Non-verse paragraph — flush any accumulated verse first
|
||||
flush_verse()
|
||||
|
||||
# Verse translation (p200) — bold translation from table
|
||||
if cls == VERSE_TRANSLATION_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
# Clean up trailing empty <b></b> artifacts
|
||||
html = re.sub(r"<b>\s*</b>", "", html).strip()
|
||||
# Normalize nested/split bold tags: strip all <b>/<\/b> and
|
||||
# re-wrap the whole text in a single <b>…</b>.
|
||||
# This fixes source artifacts like "Attracted b<b>y...up</b>on"
|
||||
inner = re.sub(r"</?b>", "", html).strip()
|
||||
html = f"<b>{inner}</b>"
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# First commentary paragraph (p201) — treat same as body
|
||||
if cls == FIRST_COMMENTARY_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Body/commentary paragraphs (p198)
|
||||
if cls == BODY_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Section heading (p27, bold)
|
||||
if cls == SECTION_HEADING_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Front pages text (p196)
|
||||
if cls == FRONT_PAGES_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Verse reference text (p203) — include as-is
|
||||
if cls == VERSE_REF_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Any other class — try to extract content (be permissive)
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
|
||||
# Flush any trailing verse
|
||||
flush_verse()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main extraction logic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_lob(soup: BeautifulSoup) -> dict:
|
||||
"""Extract the entire Light of the Bhāgavata from the parsed HTML.
|
||||
|
||||
Returns a dict with:
|
||||
"intro_segments": list of intro segment dicts
|
||||
"texts": list of numbered text dicts (1-48)
|
||||
"chapter_listing": list of {number, title} for intro.json
|
||||
"""
|
||||
all_paragraphs = list(soup.find_all("p"))
|
||||
|
||||
# Step 1: Find LoB boundaries
|
||||
lob_start, lob_end = find_lob_boundaries(all_paragraphs)
|
||||
print(f" LoB content: paragraphs {lob_start} to {lob_end}")
|
||||
print(f" Total paragraphs in range: {lob_end - lob_start}")
|
||||
|
||||
# Step 2: Find all bookmarks
|
||||
bookmarks = find_all_bookmarks(all_paragraphs, lob_start, lob_end)
|
||||
print(f" Found {len(bookmarks)} LoB bookmarks")
|
||||
for pos, text in bookmarks:
|
||||
print(f" Line ~{pos}: {text}")
|
||||
|
||||
# Step 3: Categorize bookmarks
|
||||
intro_bookmarks = [] # (para_idx, slug)
|
||||
text_bookmarks = [] # (para_idx, text_num)
|
||||
|
||||
for pos, text in bookmarks:
|
||||
text_num = parse_text_bookmark(text)
|
||||
if text_num is not None:
|
||||
text_bookmarks.append((pos, text_num))
|
||||
else:
|
||||
slug = parse_intro_bookmark(text)
|
||||
if slug is not None:
|
||||
intro_bookmarks.append((pos, slug))
|
||||
|
||||
print(f" Intro bookmarks: {len(intro_bookmarks)}")
|
||||
print(f" Text bookmarks: {len(text_bookmarks)}")
|
||||
|
||||
# Step 4: Extract front-pages (from p12 header to first intro bookmark)
|
||||
front_pages_start = lob_start + 1 # after p12 header
|
||||
if intro_bookmarks:
|
||||
front_pages_end = intro_bookmarks[0][0]
|
||||
elif text_bookmarks:
|
||||
front_pages_end = text_bookmarks[0][0]
|
||||
else:
|
||||
front_pages_end = lob_end
|
||||
|
||||
front_pages_paras = extract_paragraphs(
|
||||
all_paragraphs, front_pages_start, front_pages_end, is_front_pages=True
|
||||
)
|
||||
|
||||
front_pages = {
|
||||
"slug": "front-pages",
|
||||
"title": "Front Pages",
|
||||
"type": "intro-segment",
|
||||
"book": "lob",
|
||||
"order": 1,
|
||||
"paragraphs": front_pages_paras,
|
||||
}
|
||||
|
||||
# Step 5: Extract intro segments (preface, acknowledgement, the-artist)
|
||||
intro_segments = [front_pages]
|
||||
|
||||
# Build ordered list of all section starts for boundary computation
|
||||
all_section_starts = sorted(
|
||||
[pos for pos, _ in intro_bookmarks] + [pos for pos, _ in text_bookmarks]
|
||||
)
|
||||
|
||||
for idx, (bm_pos, slug) in enumerate(intro_bookmarks):
|
||||
content_start = bm_pos + 1 # after the bookmark paragraph
|
||||
|
||||
# Content ends at the next bookmark/section start
|
||||
next_starts = [s for s in all_section_starts if s > bm_pos]
|
||||
content_end = next_starts[0] if next_starts else lob_end
|
||||
|
||||
paras = extract_paragraphs(all_paragraphs, content_start, content_end)
|
||||
|
||||
segment = {
|
||||
"slug": slug,
|
||||
"title": INTRO_SLUG_TO_TITLE.get(slug, slug.replace("-", " ").title()),
|
||||
"type": "intro-segment",
|
||||
"book": "lob",
|
||||
"order": INTRO_SLUG_TO_ORDER.get(slug, 10 + idx),
|
||||
"paragraphs": paras,
|
||||
}
|
||||
intro_segments.append(segment)
|
||||
|
||||
# Step 6: Extract texts 1-48
|
||||
texts = []
|
||||
for idx, (txt_pos, txt_num) in enumerate(text_bookmarks):
|
||||
content_start = txt_pos + 1 # after the bookmark paragraph
|
||||
|
||||
# Content ends at the next text bookmark or lob_end
|
||||
if idx + 1 < len(text_bookmarks):
|
||||
content_end = text_bookmarks[idx + 1][0]
|
||||
else:
|
||||
content_end = lob_end
|
||||
|
||||
paragraphs = extract_paragraphs(
|
||||
all_paragraphs, content_start, content_end, is_numbered_text=True
|
||||
)
|
||||
|
||||
txt = {
|
||||
"book": "lob",
|
||||
"chapter": txt_num,
|
||||
"title": f"Text {txt_num}",
|
||||
"type": "chapter",
|
||||
"paragraphs": paragraphs,
|
||||
}
|
||||
texts.append(txt)
|
||||
|
||||
# Step 7: Build chapter listing for intro.json
|
||||
chapter_listing = [{"number": t["chapter"], "title": t["title"]} for t in texts]
|
||||
|
||||
return {
|
||||
"intro_segments": intro_segments,
|
||||
"texts": texts,
|
||||
"chapter_listing": chapter_listing,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def write_json(data, filepath):
|
||||
"""Write data as formatted JSON to filepath, creating directories as needed."""
|
||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def main():
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
html_path = os.path.join(base_dir, "public", "html", "other.html")
|
||||
output_base = os.path.join(base_dir, "lib", "jsons", "books", "lob")
|
||||
|
||||
if not os.path.exists(html_path):
|
||||
print(f"ERROR: {html_path} not found!")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Reading {html_path}...")
|
||||
with open(html_path, "r", encoding="utf-8") as f:
|
||||
soup = BeautifulSoup(f, "html.parser")
|
||||
|
||||
print("Extracting Light of the Bhāgavata (1983)...\n")
|
||||
result = extract_lob(soup)
|
||||
|
||||
intro_segments = result["intro_segments"]
|
||||
texts = result["texts"]
|
||||
chapter_listing = result["chapter_listing"]
|
||||
|
||||
# --- Write intro.json ---
|
||||
intro_meta = {
|
||||
"book": "lob",
|
||||
"title": "Light of the Bhāgavata",
|
||||
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
|
||||
"year": 1983,
|
||||
"description": "A unique blend of Vedic wisdom and art — 48 poetic descriptions of the autumn season with spiritual commentaries.",
|
||||
"totalChapters": 48,
|
||||
"chapters": chapter_listing,
|
||||
}
|
||||
intro_path = os.path.join(output_base, "intro.json")
|
||||
write_json(intro_meta, intro_path)
|
||||
print(f"\n Written: {intro_path}")
|
||||
|
||||
# --- Write intro segments ---
|
||||
print(f"\n--- Intro Segments ---")
|
||||
for seg in intro_segments:
|
||||
filepath = os.path.join(output_base, "intro", f"{seg['slug']}.json")
|
||||
write_json(seg, filepath)
|
||||
print(
|
||||
f" {seg['order']}. {seg['title']}: {len(seg['paragraphs'])} paragraphs → {filepath}"
|
||||
)
|
||||
|
||||
# --- Write texts (chapters) ---
|
||||
print(f"\n--- Texts ---")
|
||||
for txt in texts:
|
||||
num_str = str(txt["chapter"]).zfill(2)
|
||||
filepath = os.path.join(output_base, "chapters", f"{num_str}.json")
|
||||
write_json(txt, filepath)
|
||||
print(
|
||||
f" Text {txt['chapter']:>2}: {len(txt['paragraphs']):>3} paragraphs — "
|
||||
f"{txt['title']}"
|
||||
)
|
||||
|
||||
# --- Summary ---
|
||||
total_intro_paras = sum(len(s["paragraphs"]) for s in intro_segments)
|
||||
total_text_paras = sum(len(t["paragraphs"]) for t in texts)
|
||||
total_paras = total_intro_paras + total_text_paras
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" EXTRACTION SUMMARY")
|
||||
print(f"{'=' * 60}")
|
||||
print(f" Intro segments: {len(intro_segments)}")
|
||||
for seg in intro_segments:
|
||||
print(
|
||||
f" {seg['order']}. {seg['title']}: {len(seg['paragraphs'])} paragraphs"
|
||||
)
|
||||
print(f" Texts: {len(texts)}")
|
||||
print(f" Total paragraphs: {total_paras}")
|
||||
print(f" Intro: {total_intro_paras}")
|
||||
print(f" Texts: {total_text_paras}")
|
||||
if texts:
|
||||
print(f" Avg paras/text: {total_text_paras / len(texts):.1f}")
|
||||
print(f" Output directory: {output_base}/")
|
||||
|
||||
# --- Samples ---
|
||||
print(f"\n--- Sample: Text 1 ---")
|
||||
if texts:
|
||||
t1 = texts[0]
|
||||
for i, p in enumerate(t1["paragraphs"][:4]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:120]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
if len(texts) >= 24:
|
||||
print(f"\n--- Sample: Text 24 ---")
|
||||
t24 = texts[23]
|
||||
for i, p in enumerate(t24["paragraphs"][:3]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:120]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
if len(texts) >= 48:
|
||||
print(f"\n--- Sample: Text 48 (last) ---")
|
||||
t48 = texts[47]
|
||||
for i, p in enumerate(t48["paragraphs"][:3]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:120]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
# --- Quality Checks ---
|
||||
print(f"\n--- Quality Checks ---")
|
||||
|
||||
# Check all 48 texts present
|
||||
expected_texts = set(range(1, 49))
|
||||
actual_texts = set(t["chapter"] for t in texts)
|
||||
missing = expected_texts - actual_texts
|
||||
extra = actual_texts - expected_texts
|
||||
if missing:
|
||||
print(f" ⚠ Missing texts: {sorted(missing)}")
|
||||
if extra:
|
||||
print(f" ⚠ Extra texts: {sorted(extra)}")
|
||||
if not missing and not extra:
|
||||
print(f" ✅ All 48 texts present")
|
||||
|
||||
# Check all 4 intro segments present
|
||||
expected_intros = {"front-pages", "preface", "acknowledgement", "the-artist"}
|
||||
actual_intros = {s["slug"] for s in intro_segments}
|
||||
missing_intros = expected_intros - actual_intros
|
||||
if missing_intros:
|
||||
print(f" ⚠ Missing intro segments: {missing_intros}")
|
||||
else:
|
||||
print(f" ✅ All 4 intro segments present")
|
||||
|
||||
# Check each text has at least 2 paragraphs (1 translation + 1 commentary)
|
||||
thin_texts = [t["chapter"] for t in texts if len(t["paragraphs"]) < 2]
|
||||
if thin_texts:
|
||||
print(f" ⚠ Texts with fewer than 2 paragraphs: {thin_texts}")
|
||||
else:
|
||||
print(f" ✅ All texts have at least 2 paragraphs (translation + commentary)")
|
||||
|
||||
# Check empty texts
|
||||
empty_texts = [t["chapter"] for t in texts if len(t["paragraphs"]) == 0]
|
||||
if empty_texts:
|
||||
print(f" ⚠ Empty texts (0 paragraphs): {empty_texts}")
|
||||
else:
|
||||
print(f" ✅ No empty texts")
|
||||
|
||||
# Check that each numbered text starts with a bold translation
|
||||
texts_without_translation = []
|
||||
for t in texts:
|
||||
if t["paragraphs"]:
|
||||
first = t["paragraphs"][0]
|
||||
if not first.strip().startswith("<b>"):
|
||||
texts_without_translation.append(t["chapter"])
|
||||
if texts_without_translation:
|
||||
print(
|
||||
f" ⚠ Texts whose first paragraph is not a bold translation: "
|
||||
f"{texts_without_translation}"
|
||||
)
|
||||
else:
|
||||
print(f" ✅ All texts start with bold verse translation")
|
||||
|
||||
# Check ScaGoudy decoding — look for common encoded characters
|
||||
encoded_chars = set("äåöëòçüéàùè")
|
||||
suspect_count = 0
|
||||
all_sections = texts + intro_segments
|
||||
for section in all_sections:
|
||||
for p in section["paragraphs"]:
|
||||
plain = re.sub(r"<[^>]+>", "", p)
|
||||
for c in encoded_chars:
|
||||
if c in plain:
|
||||
suspect_count += 1
|
||||
break
|
||||
if suspect_count == 0:
|
||||
print(f" ✅ No suspected undecoded ScaGoudy characters found")
|
||||
else:
|
||||
print(
|
||||
f" ℹ {suspect_count} paragraphs contain characters that may be undecoded ScaGoudy"
|
||||
)
|
||||
|
||||
# Check verse blocks (embedded Sanskrit verses with p5)
|
||||
verse_block_count = 0
|
||||
for t in texts:
|
||||
for p in t["paragraphs"]:
|
||||
if '<div style="text-align:center"><i>' in p:
|
||||
verse_block_count += 1
|
||||
print(f" ℹ {verse_block_count} verse blocks in texts (embedded Sanskrit verses)")
|
||||
|
||||
# ScaGoudy decoding spot check
|
||||
print(f"\n--- ScaGoudy Decoding Spot Check ---")
|
||||
if texts:
|
||||
t1_text = " ".join(texts[0]["paragraphs"][:10])
|
||||
if "Kṛṣṇa" in t1_text or "kṛṣṇa" in t1_text:
|
||||
print(f" ✅ 'Kṛṣṇa' correctly decoded in Text 1")
|
||||
elif "Kåñëa" in t1_text or "kåñëa" in t1_text:
|
||||
print(f" ⚠ 'Kåñëa' still encoded — ScaGoudy decoding may have failed!")
|
||||
else:
|
||||
print(
|
||||
f" ℹ 'Kṛṣṇa' not found in Text 1 first 10 paragraphs (may be normal)"
|
||||
)
|
||||
|
||||
# Check for "Prabhupāda" in intro segments
|
||||
all_intro_text = " ".join(
|
||||
p for seg in intro_segments for p in seg["paragraphs"][:10]
|
||||
)
|
||||
if "Prabhupāda" in all_intro_text:
|
||||
print(f" ✅ 'Prabhupāda' correctly decoded in intro")
|
||||
elif "Prabhupäda" in all_intro_text:
|
||||
print(f" ⚠ ScaGoudy decoding incomplete for 'Prabhupäda'")
|
||||
|
||||
# Check for "Śrīmad" or "Bhāgavatam"
|
||||
if "Bhāgavata" in all_intro_text or "Bhāgawata" in all_intro_text:
|
||||
print(f" ✅ 'Bhāgavata' correctly decoded in intro")
|
||||
|
||||
# Sample verse blocks
|
||||
if verse_block_count > 0:
|
||||
print(f"\n--- Sample Verse Blocks ---")
|
||||
shown = 0
|
||||
for t in texts:
|
||||
for p in t["paragraphs"]:
|
||||
if '<div style="text-align:center"><i>' in p:
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:120]
|
||||
print(f" Text {t['chapter']}: {preview}...")
|
||||
shown += 1
|
||||
if shown >= 3:
|
||||
break
|
||||
if shown >= 3:
|
||||
break
|
||||
|
||||
print(f"\nDone! ✨")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,806 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract Mukunda-mālā-stotra verses from other.html into structured JSON files."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
from indic_transliteration import sanscript
|
||||
from indic_transliteration.sanscript import transliterate
|
||||
|
||||
# --- ScaGoudy → IAST Unicode mapping ---
|
||||
SCAGOUDY_TO_IAST = {
|
||||
"ä": "ā",
|
||||
"Ä": "Ā",
|
||||
"å": "ṛ",
|
||||
"Å": "Ṛ",
|
||||
"ñ": "ṣ",
|
||||
"Ñ": "Ṣ",
|
||||
"ö": "ṭ",
|
||||
"Ö": "Ṭ",
|
||||
"ë": "ṇ",
|
||||
"Ë": "Ṇ",
|
||||
"ò": "ḍ",
|
||||
"Ò": "Ḍ",
|
||||
"ç": "ś",
|
||||
"Ç": "Ś",
|
||||
"ì": "ṅ",
|
||||
"ï": "ñ",
|
||||
"ü": "ū",
|
||||
"Ü": "Ū",
|
||||
"é": "ī",
|
||||
"É": "Ī",
|
||||
"à": "ṁ",
|
||||
"ù": "ḥ",
|
||||
"è": "ṝ",
|
||||
"û": "ṁl",
|
||||
"ÿ": "ḷ",
|
||||
}
|
||||
|
||||
# --- CSS class constants ---
|
||||
BOOK_HEADER_CLASS = "p12" # Book title
|
||||
AUTHOR_SUBTITLE_CLASS = "p28" # Author subtitle
|
||||
INTRO_BOOKMARK_CLASS = "p64" # "MM Introduction" bookmark
|
||||
SECTION_HEADING_CLASS = "p31" # "Introduction" heading (bold)
|
||||
SUB_HEADING_CLASS = "p109" # "About the Present Edition" sub-heading
|
||||
BODY_CLASS = "p48" # Body/purport paragraphs (MAIN content class)
|
||||
ATTRIBUTION_CLASS = "p47" # "—The Publishers"
|
||||
VERSE_BOOKMARK_CLASS = "p49" # Verse bookmarks ("MM mantra N")
|
||||
VERSE_HEADING_V1_CLASS = "p6" # "TEXT 1" heading (variant for text 1)
|
||||
VERSE_HEADING_CLASS = "p57" # "TEXT N" heading for texts 2-6 (bold)
|
||||
TEXT_LABEL_CLASS = "p52" # "TEXT" label (skip)
|
||||
PADA_TRANSLIT_CLASS = "p5" # Transliteration pādas (italic)
|
||||
SYNONYMS_HEADER_CLASS = "p165" # "SYNONYMS" header
|
||||
SYNONYM_TEXT_CLASS = "p54" # Synonyms text
|
||||
TRANSLATION_LABEL_CLASS = "p55" # "TRANSLATION" / "PURPORT" headers
|
||||
TRANSLATION_TEXT_CLASS = "p56" # Translation text (bold)
|
||||
QUOTED_VERSE_CLASS = "p53" # Embedded Sanskrit verse in purport
|
||||
COMMENTATOR_CLASS = "p44" # Verse translation within purport
|
||||
|
||||
|
||||
def decode_scagoudy(text: str) -> str:
|
||||
"""Convert ScaGoudy-encoded text to proper IAST Unicode."""
|
||||
for src, dst in SCAGOUDY_TO_IAST.items():
|
||||
text = text.replace(src, dst)
|
||||
return text
|
||||
|
||||
|
||||
def decode_html_content(tag: Tag) -> str:
|
||||
"""Extract inner HTML from a tag, decode ScaGoudy, preserve formatting tags."""
|
||||
inner = "".join(str(child) for child in tag.children)
|
||||
inner = decode_scagoudy(inner)
|
||||
# Remove span tags with CSS classes (artifacts from RTF conversion)
|
||||
inner = re.sub(
|
||||
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, flags=re.DOTALL
|
||||
)
|
||||
# Clean up Apple-tab-span leftovers (tab characters)
|
||||
inner = inner.replace("\t", " ")
|
||||
# Normalize whitespace
|
||||
inner = re.sub(r"\s+", " ", inner).strip()
|
||||
return inner
|
||||
|
||||
|
||||
def decode_text(tag: Tag) -> str:
|
||||
"""Extract plain text from a tag and decode ScaGoudy."""
|
||||
text = tag.get_text()
|
||||
return decode_scagoudy(text).strip()
|
||||
|
||||
|
||||
def normalize_devanagari_anusvara(text: str) -> str:
|
||||
"""Replace explicit class nasals before same-class consonants with anusvāra.
|
||||
|
||||
Only normalize ङ् and ञ् — these are almost always written with anusvāra.
|
||||
Keep ण्ड, न्त, म्ब etc. as explicit conjuncts (standard spelling).
|
||||
"""
|
||||
replacements = [
|
||||
# ङ् before velars (क, ख, ग, घ)
|
||||
(r"ङ्([कखगघ])", r"ं\1"),
|
||||
# ञ् before palatals (च, छ, ज, झ)
|
||||
(r"ञ्([चछजझ])", r"ं\1"),
|
||||
]
|
||||
for pattern, repl in replacements:
|
||||
text = re.sub(pattern, repl, text)
|
||||
return text
|
||||
|
||||
|
||||
def join_virama_words(text: str) -> str:
|
||||
"""Join Devanagari words where the previous word ends with virāma (्).
|
||||
|
||||
Handles both virāma+consonant and virāma+vowel cases.
|
||||
Uses [ \\t]+ instead of \\s+ to avoid joining across newlines.
|
||||
"""
|
||||
# Join virāma + space + consonant (क U+0915 through ह U+0939)
|
||||
text = re.sub(r"्[ \t]+([क-ह])", r"्\1", text)
|
||||
|
||||
# Join virāma + space + independent vowel
|
||||
_VOWEL_TO_MATRA = {
|
||||
"अ": "", # inherent vowel — just drop virāma and the अ
|
||||
"आ": "ा",
|
||||
"इ": "ि",
|
||||
"ई": "ी",
|
||||
"उ": "ु",
|
||||
"ऊ": "ू",
|
||||
"ऋ": "ृ",
|
||||
"ॠ": "ॄ",
|
||||
"ऌ": "ॢ",
|
||||
"ॡ": "ॣ",
|
||||
"ए": "े",
|
||||
"ऐ": "ै",
|
||||
"ओ": "ो",
|
||||
"औ": "ौ",
|
||||
}
|
||||
|
||||
def _virama_vowel_replace(m: re.Match[str]) -> str:
|
||||
vowel = m.group(1)
|
||||
return _VOWEL_TO_MATRA.get(vowel, vowel)
|
||||
|
||||
text = re.sub(r"्[ \t]+([अ-औॠॡ])", _virama_vowel_replace, text)
|
||||
return text
|
||||
|
||||
|
||||
def iast_to_devanagari(iast_text: str) -> str:
|
||||
"""Convert IAST transliteration to Devanagari script."""
|
||||
lines = iast_text.split("\n")
|
||||
result_lines = []
|
||||
for line in lines:
|
||||
# Remove hyphens between words for Devanagari
|
||||
clean = line.replace("-", "")
|
||||
# Normalize ṁ (U+1E41) to ṃ (U+1E43) for proper anusvāra
|
||||
clean = clean.replace("ṁ", "ṃ")
|
||||
deva = transliterate(clean, sanscript.IAST, sanscript.DEVANAGARI)
|
||||
deva = normalize_devanagari_anusvara(deva)
|
||||
deva = join_virama_words(deva)
|
||||
result_lines.append(deva)
|
||||
return "\n".join(result_lines)
|
||||
|
||||
|
||||
def parse_synonyms(text: str) -> list:
|
||||
"""Parse synonym text into structured array.
|
||||
|
||||
Input format: "word—meaning; word—meaning; word—meaning"
|
||||
"""
|
||||
result = []
|
||||
entries = text.split(";")
|
||||
for entry in entries:
|
||||
entry = entry.strip()
|
||||
if not entry:
|
||||
continue
|
||||
if "—" in entry:
|
||||
parts = entry.split("—", 1)
|
||||
elif "--" in entry:
|
||||
parts = entry.split("--", 1)
|
||||
else:
|
||||
continue
|
||||
|
||||
if len(parts) == 2:
|
||||
word = parts[0].strip()
|
||||
meaning = parts[1].strip()
|
||||
# Clean word: remove trailing/leading punctuation
|
||||
word = word.strip("., ")
|
||||
if word and meaning:
|
||||
result.append({"word": word, "meanings": [meaning]})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def pair_padas(padas: list) -> str:
|
||||
"""Combine pādas into 2-line verse format.
|
||||
|
||||
If the number of pādas is odd, the first one is treated as a
|
||||
standalone line (e.g. speaker attribution like 'uvāca').
|
||||
|
||||
Empty strings in the list act as stanza-break sentinels and are
|
||||
rendered as blank lines in the output.
|
||||
"""
|
||||
if not padas:
|
||||
return ""
|
||||
|
||||
# Split pādas into stanza groups separated by "" sentinels
|
||||
groups: list[list[str]] = [[]]
|
||||
for p in padas:
|
||||
if p == "":
|
||||
groups.append([])
|
||||
else:
|
||||
groups[-1].append(p)
|
||||
|
||||
result_lines = []
|
||||
for group in groups:
|
||||
if not group:
|
||||
continue
|
||||
|
||||
lines = []
|
||||
i = 0
|
||||
|
||||
# If odd number of pādas, first one is standalone (speaker attribution)
|
||||
if len(group) % 2 == 1:
|
||||
lines.append(group[0])
|
||||
i = 1
|
||||
|
||||
# Pair remaining pādas
|
||||
while i < len(group) - 1:
|
||||
lines.append(group[i] + " " + group[i + 1])
|
||||
i += 2
|
||||
|
||||
# Handle any trailing single pāda
|
||||
if i < len(group):
|
||||
lines.append(group[i])
|
||||
|
||||
result_lines.append("\n".join(lines))
|
||||
|
||||
return "\n\n".join(result_lines)
|
||||
|
||||
|
||||
def get_css_class(tag: Tag) -> str:
|
||||
"""Get the primary CSS class of a tag."""
|
||||
classes = tag.get("class", [])
|
||||
return classes[0] if classes else ""
|
||||
|
||||
|
||||
def is_empty(tag: Tag) -> bool:
|
||||
"""Check if a tag is empty or whitespace-only."""
|
||||
text = tag.get_text().strip()
|
||||
return not text or text == "\xa0"
|
||||
|
||||
|
||||
def write_json(data, filepath):
|
||||
"""Write JSON with proper Unicode and formatting."""
|
||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
print(f" Written: {filepath}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Boundary detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def find_mm_boundaries(all_paragraphs: list) -> tuple[int, int]:
|
||||
"""Find the start and end paragraph indices of the Mukunda-mālā-stotra content.
|
||||
|
||||
Start: <p class="p12"> containing "Mukunda-mälä-stotra" (ScaGoudy encoded)
|
||||
End: next <p class="p12"> after start (start of next book — Nārada-bhakti-sūtra)
|
||||
"""
|
||||
start_idx = None
|
||||
end_idx = len(all_paragraphs)
|
||||
|
||||
for i, p in enumerate(all_paragraphs):
|
||||
cls = get_css_class(p)
|
||||
if cls == BOOK_HEADER_CLASS:
|
||||
text = p.get_text().strip()
|
||||
if start_idx is None and "Mukunda" in text:
|
||||
start_idx = i
|
||||
continue
|
||||
# Next p12 after our start → end boundary
|
||||
if start_idx is not None:
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
if start_idx is None:
|
||||
print("ERROR: Could not find 'Mukunda-mālā-stotra' header in other.html!")
|
||||
sys.exit(1)
|
||||
|
||||
return start_idx, end_idx
|
||||
|
||||
|
||||
def parse_verse_bookmark(text: str) -> int | None:
|
||||
"""Parse a verse bookmark like 'MM mantra N'.
|
||||
|
||||
Returns mantra number (1-6), or None.
|
||||
"""
|
||||
text = text.strip()
|
||||
m = re.match(r"MM\s+mantra\s+(\d+)", text)
|
||||
if m:
|
||||
return int(m.group(1))
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Section boundary detection within MM range
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def find_section_boundaries(all_paragraphs: list, start_idx: int, end_idx: int) -> dict:
|
||||
"""Find key section boundaries within the MM content.
|
||||
|
||||
Returns dict with section names and their start indices.
|
||||
"""
|
||||
sections = {}
|
||||
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
text = p.get_text().strip()
|
||||
|
||||
# Introduction bookmark (p64 with "MM Introduction")
|
||||
if cls == INTRO_BOOKMARK_CLASS and "MM Introduction" in text:
|
||||
sections["introduction_bookmark"] = i
|
||||
|
||||
# First verse bookmark (MM mantra 1)
|
||||
elif cls == VERSE_BOOKMARK_CLASS:
|
||||
decoded = decode_text(p)
|
||||
vnum = parse_verse_bookmark(decoded)
|
||||
if vnum == 1 and "first_verse" not in sections:
|
||||
sections["first_verse"] = i
|
||||
break
|
||||
|
||||
return sections
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Intro segment extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_intro_segment(
|
||||
all_paragraphs: list, start_idx: int, end_idx: int
|
||||
) -> list[str]:
|
||||
"""Extract paragraphs for the introduction segment.
|
||||
|
||||
Decodes ScaGoudy, preserves HTML formatting, skips empty paragraphs.
|
||||
Includes p48 (body), p47 (attribution), p109 (sub-heading) content.
|
||||
"""
|
||||
result = []
|
||||
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
|
||||
if is_empty(p):
|
||||
continue
|
||||
|
||||
# Skip bookmark and header classes
|
||||
if cls in (
|
||||
INTRO_BOOKMARK_CLASS,
|
||||
BOOK_HEADER_CLASS,
|
||||
SECTION_HEADING_CLASS,
|
||||
AUTHOR_SUBTITLE_CLASS,
|
||||
):
|
||||
continue
|
||||
|
||||
# Include body, attribution, and sub-heading classes
|
||||
if cls in (BODY_CLASS, ATTRIBUTION_CLASS, SUB_HEADING_CLASS):
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verse extraction state machine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_verses(all_paragraphs: list, verses_start: int, verses_end: int) -> list:
|
||||
"""Extract verses from the MM content using a state machine.
|
||||
|
||||
States: IDLE → VERSE_START → TRANSLITERATION → SYNONYMS →
|
||||
TRANSLATION → POST_TRANSLATION → PURPORT → (back to IDLE)
|
||||
|
||||
Returns list of verse dicts.
|
||||
"""
|
||||
verses = []
|
||||
current_verse = None
|
||||
state = "IDLE"
|
||||
|
||||
for i in range(verses_start, verses_end):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
|
||||
# --- Verse bookmark (p49) ---
|
||||
if cls == VERSE_BOOKMARK_CLASS:
|
||||
# Finalize previous verse if any
|
||||
if current_verse and state != "IDLE":
|
||||
_finalize_verse(current_verse)
|
||||
verses.append(current_verse)
|
||||
|
||||
text = decode_text(p)
|
||||
verse_num = parse_verse_bookmark(text)
|
||||
if verse_num is not None:
|
||||
reference = f"MM {verse_num}"
|
||||
|
||||
current_verse = {
|
||||
"book_title": "Mukunda-mālā-stotra",
|
||||
"chapter": 1,
|
||||
"verse": verse_num,
|
||||
"reference": reference,
|
||||
"devanagari": "",
|
||||
"transliteration": "",
|
||||
"_padas": [],
|
||||
"synonyms": [],
|
||||
"translation": "",
|
||||
"purport": [],
|
||||
}
|
||||
state = "VERSE_START"
|
||||
continue
|
||||
|
||||
if not current_verse:
|
||||
continue
|
||||
|
||||
# --- Verse heading (p6 for text 1, p57 for texts 2-6) - confirms text number ---
|
||||
if cls in (VERSE_HEADING_V1_CLASS, VERSE_HEADING_CLASS):
|
||||
inner_text = p.get_text().strip().upper()
|
||||
# If this says "TEXT N" it's a verse heading — skip it
|
||||
if "TEXT" in inner_text and state == "VERSE_START":
|
||||
continue
|
||||
# p57 can also be "PURPORT" header
|
||||
if "PURPORT" in inner_text:
|
||||
state = "PURPORT"
|
||||
continue
|
||||
# p57 can also be "TRANSLATION" header
|
||||
if "TRANSLATION" in inner_text:
|
||||
state = "TRANSLATION"
|
||||
continue
|
||||
# Otherwise skip if we're in VERSE_START
|
||||
if state == "VERSE_START":
|
||||
continue
|
||||
|
||||
# --- TEXT label (p52) - transition to transliteration ---
|
||||
if cls == TEXT_LABEL_CLASS:
|
||||
state = "TRANSLITERATION"
|
||||
continue
|
||||
|
||||
# --- Pāda transliteration lines (p5) ---
|
||||
if cls == PADA_TRANSLIT_CLASS and state in ("VERSE_START", "TRANSLITERATION"):
|
||||
state = "TRANSLITERATION"
|
||||
line = decode_text(p)
|
||||
# Remove trailing period
|
||||
line = line.rstrip(".")
|
||||
line = line.strip()
|
||||
if line:
|
||||
current_verse["_padas"].append(line)
|
||||
continue
|
||||
|
||||
# --- SYNONYMS header (p165) ---
|
||||
if cls == SYNONYMS_HEADER_CLASS:
|
||||
state = "SYNONYMS"
|
||||
continue
|
||||
|
||||
# --- Synonyms text (p54) ---
|
||||
if cls == SYNONYM_TEXT_CLASS and current_verse:
|
||||
state = "SYNONYMS"
|
||||
syn_text = decode_text(p)
|
||||
syn_text = syn_text.strip()
|
||||
parsed = parse_synonyms(syn_text)
|
||||
current_verse["synonyms"].extend(parsed)
|
||||
continue
|
||||
|
||||
# --- TRANSLATION label (p55) — check text content to distinguish from PURPORT ---
|
||||
if cls == TRANSLATION_LABEL_CLASS and current_verse:
|
||||
inner_text = p.get_text().strip().upper()
|
||||
if "TRANSLATION" in inner_text:
|
||||
state = "TRANSLATION"
|
||||
elif "PURPORT" in inner_text:
|
||||
state = "PURPORT"
|
||||
continue
|
||||
|
||||
# --- PURPORT label via p57 (alt class) ---
|
||||
if cls == VERSE_HEADING_CLASS and current_verse:
|
||||
inner_text = p.get_text().strip().upper()
|
||||
if "PURPORT" in inner_text:
|
||||
state = "PURPORT"
|
||||
continue
|
||||
elif "TRANSLATION" in inner_text:
|
||||
state = "TRANSLATION"
|
||||
continue
|
||||
|
||||
# --- Translation text (p56) ---
|
||||
if cls == TRANSLATION_TEXT_CLASS and current_verse and state == "TRANSLATION":
|
||||
html = decode_html_content(p)
|
||||
# Remove bold wrapper if present (the whole translation is bold)
|
||||
html = re.sub(r"^<b>(.*)</b>$", r"\1", html, flags=re.DOTALL)
|
||||
html = html.strip()
|
||||
current_verse["translation"] = html
|
||||
state = "POST_TRANSLATION"
|
||||
continue
|
||||
|
||||
# --- Purport paragraphs (p48) ---
|
||||
if (
|
||||
cls == BODY_CLASS
|
||||
and current_verse
|
||||
and state in ("PURPORT", "POST_TRANSLATION")
|
||||
):
|
||||
state = "PURPORT"
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
current_verse["purport"].append(html)
|
||||
continue
|
||||
|
||||
# --- Embedded Sanskrit verse in purport (p53) ---
|
||||
if cls == QUOTED_VERSE_CLASS and current_verse and state == "PURPORT":
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
# Wrap as centered italic div for display
|
||||
current_verse["purport"].append(
|
||||
f'<div class="quoted-verse">{html}</div>'
|
||||
)
|
||||
continue
|
||||
|
||||
# --- Verse translation within purport (p44) ---
|
||||
if cls == COMMENTATOR_CLASS and current_verse and state == "PURPORT":
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
current_verse["purport"].append(html)
|
||||
continue
|
||||
|
||||
# --- p6 paragraphs in purport context (can appear as body text) ---
|
||||
if (
|
||||
cls == VERSE_HEADING_V1_CLASS
|
||||
and current_verse
|
||||
and state in ("PURPORT", "POST_TRANSLATION")
|
||||
):
|
||||
state = "PURPORT"
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
current_verse["purport"].append(html)
|
||||
continue
|
||||
|
||||
# Finalize last verse
|
||||
if current_verse and state != "IDLE":
|
||||
_finalize_verse(current_verse)
|
||||
verses.append(current_verse)
|
||||
|
||||
return verses
|
||||
|
||||
|
||||
def _finalize_verse(verse: dict):
|
||||
"""Build transliteration and Devanagari from pādas. Remove internal fields."""
|
||||
padas = verse.get("_padas", [])
|
||||
|
||||
# Transliteration: one pāda per line, blank sentinels become blank lines
|
||||
trans_lines = []
|
||||
for p in padas:
|
||||
if p == "":
|
||||
trans_lines.append("") # blank line between stanza groups
|
||||
else:
|
||||
trans_lines.append(p)
|
||||
verse["transliteration"] = "\n".join(trans_lines)
|
||||
|
||||
# Devanagari: pair pādas into 2-line verse structure, then convert
|
||||
if padas:
|
||||
paired = pair_padas(padas)
|
||||
verse["devanagari"] = iast_to_devanagari(paired)
|
||||
|
||||
# Clean up internal field
|
||||
if "_padas" in verse:
|
||||
del verse["_padas"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main():
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
html_path = os.path.join(base_dir, "public", "html", "other.html")
|
||||
output_base = os.path.join(base_dir, "lib", "jsons", "books", "mm")
|
||||
|
||||
if not os.path.exists(html_path):
|
||||
print(f"ERROR: {html_path} not found!")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Reading {html_path}...")
|
||||
with open(html_path, "r", encoding="utf-8") as f:
|
||||
soup = BeautifulSoup(f, "html.parser")
|
||||
|
||||
all_paragraphs = soup.find_all("p")
|
||||
print(f"Total paragraphs in file: {len(all_paragraphs)}")
|
||||
|
||||
# --- Find MM boundaries ---
|
||||
mm_start, mm_end = find_mm_boundaries(all_paragraphs)
|
||||
print(f"MM content: paragraphs {mm_start} to {mm_end}")
|
||||
|
||||
# --- Find section boundaries ---
|
||||
sections = find_section_boundaries(all_paragraphs, mm_start, mm_end)
|
||||
print(f"\nSection boundaries found:")
|
||||
for name, idx in sorted(sections.items(), key=lambda x: x[1]):
|
||||
print(f" [{idx}] {name}")
|
||||
|
||||
intro_bm = sections.get("introduction_bookmark")
|
||||
first_verse = sections.get("first_verse")
|
||||
|
||||
if first_verse is None:
|
||||
print("ERROR: Could not find first verse bookmark (MM mantra 1)!")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Extract intro segment ---
|
||||
print("\n--- Extracting Intro Segment ---")
|
||||
|
||||
intro_segments = []
|
||||
|
||||
if intro_bm is not None:
|
||||
intro_end = first_verse
|
||||
intro_paras = extract_intro_segment(all_paragraphs, intro_bm, intro_end)
|
||||
intro_segment = {
|
||||
"slug": "introduction",
|
||||
"title": "Introduction",
|
||||
"type": "intro-segment",
|
||||
"book": "mm",
|
||||
"order": 1,
|
||||
"paragraphs": intro_paras,
|
||||
}
|
||||
intro_segments.append(intro_segment)
|
||||
print(f" Introduction: {len(intro_paras)} paragraphs")
|
||||
|
||||
# --- Extract verses ---
|
||||
print("\n--- Extracting Verses ---")
|
||||
|
||||
verses = extract_verses(all_paragraphs, first_verse, mm_end)
|
||||
print(f"Extracted {len(verses)} verses (expected 6: Texts 1-6)")
|
||||
|
||||
# --- Write intro.json ---
|
||||
intro_meta = {
|
||||
"book": "mm",
|
||||
"title": "Mukunda-mālā-stotra",
|
||||
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
|
||||
"year": 1989,
|
||||
"description": "King Kulaśekhara's prayers to Lord Mukunda — translations and purports for verses 1–6.",
|
||||
"totalChapters": 1,
|
||||
"chapters": [
|
||||
{
|
||||
"number": 1,
|
||||
"title": "Prayers of King Kulaśekhara",
|
||||
"totalVerses": 6,
|
||||
}
|
||||
],
|
||||
}
|
||||
write_json(intro_meta, os.path.join(output_base, "intro.json"))
|
||||
|
||||
# --- Write intro segments ---
|
||||
print("\n--- Writing Intro Segments ---")
|
||||
for seg in intro_segments:
|
||||
filepath = os.path.join(output_base, "intro", f"{seg['slug']}.json")
|
||||
write_json(seg, filepath)
|
||||
|
||||
# --- Write verse JSON files ---
|
||||
print("\n--- Writing Verse Files ---")
|
||||
for verse in verses:
|
||||
text_num = verse["verse"]
|
||||
filename = f"{str(text_num).zfill(2)}.json"
|
||||
filepath = os.path.join(output_base, "chapters", "01", "verses", filename)
|
||||
write_json(verse, filepath)
|
||||
|
||||
# --- Summary ---
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" EXTRACTION SUMMARY")
|
||||
print(f"{'=' * 60}")
|
||||
print(f" Book: Mukunda-mālā-stotra")
|
||||
print(f" Intro segments: {len(intro_segments)}")
|
||||
for seg in intro_segments:
|
||||
print(
|
||||
f" {seg['order']}. {seg['title']}: {len(seg['paragraphs'])} paragraphs"
|
||||
)
|
||||
print(f" Verses extracted: {len(verses)}")
|
||||
texts_found = sorted([v["verse"] for v in verses])
|
||||
if texts_found:
|
||||
print(
|
||||
f" Texts: {texts_found[0]}-{texts_found[-1]} ({len(texts_found)} total)"
|
||||
)
|
||||
else:
|
||||
print(" Texts: None")
|
||||
|
||||
total_purport_paras = sum(len(v["purport"]) for v in verses)
|
||||
total_synonyms = sum(len(v["synonyms"]) for v in verses)
|
||||
print(f" Total purport ¶s: {total_purport_paras}")
|
||||
print(f" Total synonyms: {total_synonyms}")
|
||||
print(f" Output directory: {output_base}/")
|
||||
|
||||
# --- Quality checks ---
|
||||
print(f"\n--- Quality Checks ---")
|
||||
|
||||
# Check all verses have expected fields
|
||||
issues = []
|
||||
for verse in verses:
|
||||
ref = verse["reference"]
|
||||
if not verse["transliteration"]:
|
||||
issues.append(f" {ref}: missing transliteration")
|
||||
if not verse["devanagari"]:
|
||||
issues.append(f" {ref}: missing devanagari")
|
||||
if not verse["synonyms"]:
|
||||
issues.append(f" {ref}: missing synonyms")
|
||||
if not verse["translation"]:
|
||||
issues.append(f" {ref}: missing translation")
|
||||
if not verse["purport"]:
|
||||
issues.append(f" {ref}: missing purport (may be intentional)")
|
||||
|
||||
if issues:
|
||||
print(" ISSUES FOUND:")
|
||||
for issue in issues:
|
||||
print(issue)
|
||||
else:
|
||||
print(
|
||||
" All verses have transliteration, devanagari, synonyms, and translation ✓"
|
||||
)
|
||||
|
||||
# Check expected verse count
|
||||
expected_texts = set(range(1, 7))
|
||||
found_texts = set(v["verse"] for v in verses)
|
||||
missing = expected_texts - found_texts
|
||||
extra = found_texts - expected_texts
|
||||
if missing:
|
||||
print(f" MISSING texts: {sorted(missing)}")
|
||||
if extra:
|
||||
print(f" UNEXPECTED texts: {sorted(extra)}")
|
||||
if not missing and not extra:
|
||||
print(f" All 6 texts found ✓")
|
||||
|
||||
# --- ScaGoudy spot checks ---
|
||||
print(f"\n--- ScaGoudy Spot Checks ---")
|
||||
for verse in verses:
|
||||
ref = verse["reference"]
|
||||
trans = verse["transliteration"]
|
||||
# Check for leftover ScaGoudy characters
|
||||
leftover = set()
|
||||
for ch in trans:
|
||||
if ch in SCAGOUDY_TO_IAST:
|
||||
leftover.add(ch)
|
||||
if leftover:
|
||||
print(
|
||||
f" WARNING: {ref} transliteration has leftover ScaGoudy chars: {leftover}"
|
||||
)
|
||||
else:
|
||||
print(f" {ref}: transliteration clean ✓")
|
||||
|
||||
# --- Samples ---
|
||||
print(f"\n--- Sample: Text 1 (first) ---")
|
||||
t1 = [v for v in verses if v["verse"] == 1]
|
||||
if t1:
|
||||
v = t1[0]
|
||||
print(f" Reference: {v['reference']}")
|
||||
print(f" Transliteration: {v['transliteration'][:100]}...")
|
||||
print(f" Devanagari: {v['devanagari'][:100]}...")
|
||||
print(f" Synonyms: {len(v['synonyms'])} entries")
|
||||
if v["synonyms"]:
|
||||
print(f" First: {v['synonyms'][0]}")
|
||||
print(f" Last: {v['synonyms'][-1]}")
|
||||
trans_preview = re.sub(r"<[^>]+>", "", v["translation"])[:120]
|
||||
print(f" Translation: {trans_preview}...")
|
||||
print(f" Purport ¶s: {len(v['purport'])}")
|
||||
|
||||
print(f"\n--- Sample: Text 3 (middle) ---")
|
||||
t3 = [v for v in verses if v["verse"] == 3]
|
||||
if t3:
|
||||
v = t3[0]
|
||||
print(f" Reference: {v['reference']}")
|
||||
print(f" Transliteration: {v['transliteration'][:100]}...")
|
||||
print(f" Devanagari: {v['devanagari'][:100]}...")
|
||||
print(f" Synonyms: {len(v['synonyms'])} entries")
|
||||
if v["synonyms"]:
|
||||
print(f" First: {v['synonyms'][0]}")
|
||||
print(f" Last: {v['synonyms'][-1]}")
|
||||
trans_preview = re.sub(r"<[^>]+>", "", v["translation"])[:120]
|
||||
print(f" Translation: {trans_preview}...")
|
||||
print(f" Purport ¶s: {len(v['purport'])}")
|
||||
|
||||
print(f"\n--- Sample: Text 6 (last) ---")
|
||||
t6 = [v for v in verses if v["verse"] == 6]
|
||||
if t6:
|
||||
v = t6[0]
|
||||
print(f" Reference: {v['reference']}")
|
||||
print(f" Transliteration: {v['transliteration'][:100]}...")
|
||||
print(f" Devanagari: {v['devanagari'][:100]}...")
|
||||
print(f" Synonyms: {len(v['synonyms'])} entries")
|
||||
if v["synonyms"]:
|
||||
print(f" First: {v['synonyms'][0]}")
|
||||
print(f" Last: {v['synonyms'][-1]}")
|
||||
trans_preview = re.sub(r"<[^>]+>", "", v["translation"])[:120]
|
||||
print(f" Translation: {trans_preview}...")
|
||||
print(f" Purport ¶s: {len(v['purport'])}")
|
||||
|
||||
# --- Intro segment details ---
|
||||
print(f"\n--- Intro Segment Details ---")
|
||||
for seg in intro_segments:
|
||||
print(f" {seg['title']}: {len(seg['paragraphs'])} paragraphs")
|
||||
if seg["paragraphs"]:
|
||||
first_preview = re.sub(r"<[^>]+>", "", seg["paragraphs"][0])[:120]
|
||||
print(f" First ¶: {first_preview}...")
|
||||
last_preview = re.sub(r"<[^>]+>", "", seg["paragraphs"][-1])[:120]
|
||||
print(f" Last ¶: {last_preview}...")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,808 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract Nārada-bhakti-sūtra verses from other.html into structured JSON files."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
from indic_transliteration import sanscript
|
||||
from indic_transliteration.sanscript import transliterate
|
||||
|
||||
# --- ScaGoudy → IAST Unicode mapping ---
|
||||
SCAGOUDY_TO_IAST = {
|
||||
"ä": "ā",
|
||||
"Ä": "Ā",
|
||||
"å": "ṛ",
|
||||
"Å": "Ṛ",
|
||||
"ñ": "ṣ",
|
||||
"Ñ": "Ṣ",
|
||||
"ö": "ṭ",
|
||||
"Ö": "Ṭ",
|
||||
"ë": "ṇ",
|
||||
"Ë": "Ṇ",
|
||||
"ò": "ḍ",
|
||||
"Ò": "Ḍ",
|
||||
"ç": "ś",
|
||||
"Ç": "Ś",
|
||||
"ì": "ṅ",
|
||||
"ï": "ñ",
|
||||
"ü": "ū",
|
||||
"Ü": "Ū",
|
||||
"é": "ī",
|
||||
"É": "Ī",
|
||||
"à": "ṁ",
|
||||
"ù": "ḥ",
|
||||
"è": "ṝ",
|
||||
"û": "ṁl",
|
||||
"ÿ": "ḷ",
|
||||
}
|
||||
|
||||
# --- CSS class constants ---
|
||||
BOOK_HEADER_CLASS = "p12" # Book title
|
||||
SECTION_HEADING_CLASS = (
|
||||
"p13" # Section bookmarks ("NBS Introduction", "NBS: The Value of Devotion")
|
||||
)
|
||||
CHAPTER_NUM_CLASS = "p31" # "Chapter 1" heading
|
||||
CHAPTER_TITLE_CLASS = "p27" # "The Value of Devotion" heading
|
||||
BODY_CLASS = "p48" # Body/purport paragraphs (MAIN content class)
|
||||
VERSE_BOOKMARK_CLASS = "p49" # Verse bookmarks ("NBS 1" through "NBS 8")
|
||||
VERSE_HEADING_CLASS = "p50" # "SŪTRA N" heading (bold)
|
||||
DEVANAGARI_SKIP_CLASS_1 = "p220" # Garbled Devanagari in custom font — SKIP
|
||||
DEVANAGARI_SKIP_CLASS_2 = "p51" # Also garbled Devanagari — SKIP
|
||||
TEXT_LABEL_CLASS = "p52" # "SŪTRA" label
|
||||
PADA_TRANSLIT_CLASS = "p5" # Transliteration pādas (italic)
|
||||
SYNONYMS_HEADER_CLASS = "p165" # "SYNONYMS" header
|
||||
SYNONYM_TEXT_CLASS = "p221" # Synonyms text (NOTE: p221 for NBS, not p54!)
|
||||
SYNONYM_TEXT_CLASS_ALT = "p54" # Alternate synonyms class (just in case)
|
||||
TRANSLATION_LABEL_CLASS = "p55" # "TRANSLATION" / "PURPORT" headers
|
||||
TRANSLATION_TEXT_CLASS = "p56" # Translation text (bold)
|
||||
PURPORT_LABEL_ALT_CLASS = "p57" # Alternate "PURPORT" header
|
||||
QUOTED_VERSE_CLASS = "p53" # Embedded Sanskrit verse in purport
|
||||
COMMENTATOR_CLASS = "p44" # Verse translation within purport
|
||||
|
||||
|
||||
def decode_scagoudy(text: str) -> str:
|
||||
"""Convert ScaGoudy-encoded text to proper IAST Unicode."""
|
||||
for src, dst in SCAGOUDY_TO_IAST.items():
|
||||
text = text.replace(src, dst)
|
||||
return text
|
||||
|
||||
|
||||
def decode_html_content(tag: Tag) -> str:
|
||||
"""Extract inner HTML from a tag, decode ScaGoudy, preserve formatting tags."""
|
||||
inner = "".join(str(child) for child in tag.children)
|
||||
inner = decode_scagoudy(inner)
|
||||
# Remove span tags with CSS classes (artifacts from RTF conversion)
|
||||
inner = re.sub(
|
||||
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, flags=re.DOTALL
|
||||
)
|
||||
# Clean up Apple-tab-span leftovers (tab characters)
|
||||
inner = inner.replace("\t", " ")
|
||||
# Normalize whitespace
|
||||
inner = re.sub(r"\s+", " ", inner).strip()
|
||||
return inner
|
||||
|
||||
|
||||
def decode_text(tag: Tag) -> str:
|
||||
"""Extract plain text from a tag and decode ScaGoudy."""
|
||||
text = tag.get_text()
|
||||
return decode_scagoudy(text).strip()
|
||||
|
||||
|
||||
def normalize_devanagari_anusvara(text: str) -> str:
|
||||
"""Replace explicit class nasals before same-class consonants with anusvāra.
|
||||
|
||||
Only normalize ङ् and ञ् — these are almost always written with anusvāra.
|
||||
Keep ण्ड, न्त, म्ब etc. as explicit conjuncts (standard spelling).
|
||||
"""
|
||||
replacements = [
|
||||
# ङ् before velars (क, ख, ग, घ)
|
||||
(r"ङ्([कखगघ])", r"ं\1"),
|
||||
# ञ् before palatals (च, छ, ज, झ)
|
||||
(r"ञ्([चछजझ])", r"ं\1"),
|
||||
]
|
||||
for pattern, repl in replacements:
|
||||
text = re.sub(pattern, repl, text)
|
||||
return text
|
||||
|
||||
|
||||
def join_virama_words(text: str) -> str:
|
||||
"""Join Devanagari words where the previous word ends with virāma (्).
|
||||
|
||||
Handles both virāma+consonant and virāma+vowel cases.
|
||||
Uses [ \\t]+ instead of \\s+ to avoid joining across newlines.
|
||||
"""
|
||||
# Join virāma + space + consonant (क U+0915 through ह U+0939)
|
||||
text = re.sub(r"्[ \t]+([क-ह])", r"्\1", text)
|
||||
|
||||
# Join virāma + space + independent vowel
|
||||
_VOWEL_TO_MATRA = {
|
||||
"अ": "", # inherent vowel — just drop virāma and the अ
|
||||
"आ": "ा",
|
||||
"इ": "ि",
|
||||
"ई": "ी",
|
||||
"उ": "ु",
|
||||
"ऊ": "ू",
|
||||
"ऋ": "ृ",
|
||||
"ॠ": "ॄ",
|
||||
"ऌ": "ॢ",
|
||||
"ॡ": "ॣ",
|
||||
"ए": "े",
|
||||
"ऐ": "ै",
|
||||
"ओ": "ो",
|
||||
"औ": "ौ",
|
||||
}
|
||||
|
||||
def _virama_vowel_replace(m: re.Match[str]) -> str:
|
||||
vowel = m.group(1)
|
||||
return _VOWEL_TO_MATRA.get(vowel, vowel)
|
||||
|
||||
text = re.sub(r"्[ \t]+([अ-औॠॡ])", _virama_vowel_replace, text)
|
||||
return text
|
||||
|
||||
|
||||
def iast_to_devanagari(iast_text: str) -> str:
|
||||
"""Convert IAST transliteration to Devanagari script."""
|
||||
lines = iast_text.split("\n")
|
||||
result_lines = []
|
||||
for line in lines:
|
||||
# Remove hyphens between words for Devanagari
|
||||
clean = line.replace("-", "")
|
||||
# Normalize ṁ (U+1E41) to ṃ (U+1E43) for proper anusvāra
|
||||
clean = clean.replace("ṁ", "ṃ")
|
||||
deva = transliterate(clean, sanscript.IAST, sanscript.DEVANAGARI)
|
||||
deva = normalize_devanagari_anusvara(deva)
|
||||
deva = join_virama_words(deva)
|
||||
result_lines.append(deva)
|
||||
return "\n".join(result_lines)
|
||||
|
||||
|
||||
def parse_synonyms(text: str) -> list:
|
||||
"""Parse synonym text into structured array.
|
||||
|
||||
Input format: "word—meaning; word—meaning; word—meaning"
|
||||
"""
|
||||
result = []
|
||||
entries = text.split(";")
|
||||
for entry in entries:
|
||||
entry = entry.strip()
|
||||
if not entry:
|
||||
continue
|
||||
if "—" in entry:
|
||||
parts = entry.split("—", 1)
|
||||
elif "--" in entry:
|
||||
parts = entry.split("--", 1)
|
||||
else:
|
||||
continue
|
||||
|
||||
if len(parts) == 2:
|
||||
word = parts[0].strip()
|
||||
meaning = parts[1].strip()
|
||||
# Clean word: remove trailing/leading punctuation
|
||||
word = word.strip("., ")
|
||||
if word and meaning:
|
||||
result.append({"word": word, "meanings": [meaning]})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def pair_padas(padas: list) -> str:
|
||||
"""Combine pādas into 2-line verse format.
|
||||
|
||||
If the number of pādas is odd, the first one is treated as a
|
||||
standalone line (e.g. speaker attribution like 'uvāca').
|
||||
|
||||
Empty strings in the list act as stanza-break sentinels and are
|
||||
rendered as blank lines in the output.
|
||||
"""
|
||||
if not padas:
|
||||
return ""
|
||||
|
||||
# Split pādas into stanza groups separated by "" sentinels
|
||||
groups: list[list[str]] = [[]]
|
||||
for p in padas:
|
||||
if p == "":
|
||||
groups.append([])
|
||||
else:
|
||||
groups[-1].append(p)
|
||||
|
||||
result_lines = []
|
||||
for group in groups:
|
||||
if not group:
|
||||
continue
|
||||
|
||||
lines = []
|
||||
i = 0
|
||||
|
||||
# If odd number of pādas, first one is standalone (speaker attribution)
|
||||
if len(group) % 2 == 1:
|
||||
lines.append(group[0])
|
||||
i = 1
|
||||
|
||||
# Pair remaining pādas
|
||||
while i < len(group) - 1:
|
||||
lines.append(group[i] + " " + group[i + 1])
|
||||
i += 2
|
||||
|
||||
# Handle any trailing single pāda
|
||||
if i < len(group):
|
||||
lines.append(group[i])
|
||||
|
||||
result_lines.append("\n".join(lines))
|
||||
|
||||
return "\n\n".join(result_lines)
|
||||
|
||||
|
||||
def get_css_class(tag: Tag) -> str:
|
||||
"""Get the primary CSS class of a tag."""
|
||||
classes = tag.get("class", [])
|
||||
return classes[0] if classes else ""
|
||||
|
||||
|
||||
def is_empty(tag: Tag) -> bool:
|
||||
"""Check if a tag is empty or whitespace-only."""
|
||||
text = tag.get_text().strip()
|
||||
return not text or text == "\xa0"
|
||||
|
||||
|
||||
def write_json(data, filepath):
|
||||
"""Write JSON with proper Unicode and formatting."""
|
||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
print(f" Written: {filepath}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Boundary detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def find_nbs_boundaries(all_paragraphs: list) -> tuple[int, int]:
|
||||
"""Find the start and end paragraph indices of the Nārada-bhakti-sūtra content.
|
||||
|
||||
Start: <p class="p12"> containing "Närada-bhakti-sütra" (ScaGoudy encoded)
|
||||
End: next <p class="p12"> after start (start of next book)
|
||||
"""
|
||||
start_idx = None
|
||||
end_idx = len(all_paragraphs)
|
||||
|
||||
for i, p in enumerate(all_paragraphs):
|
||||
cls = get_css_class(p)
|
||||
if cls == BOOK_HEADER_CLASS:
|
||||
text = p.get_text().strip()
|
||||
if start_idx is None and (
|
||||
"rada-bhakti" in text or "Närada" in text or "Narada" in text
|
||||
):
|
||||
start_idx = i
|
||||
continue
|
||||
# Next p12 after our start → end boundary
|
||||
if start_idx is not None:
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
if start_idx is None:
|
||||
print("ERROR: Could not find 'Nārada-bhakti-sūtra' header in other.html!")
|
||||
sys.exit(1)
|
||||
|
||||
return start_idx, end_idx
|
||||
|
||||
|
||||
def parse_verse_bookmark(text: str) -> int | None:
|
||||
"""Parse a verse bookmark like 'NBS N'.
|
||||
|
||||
Returns sūtra number (1-8), or None.
|
||||
"""
|
||||
text = text.strip()
|
||||
m = re.match(r"NBS\s+(\d+)", text)
|
||||
if m:
|
||||
return int(m.group(1))
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Section boundary detection within NBS range
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def find_section_boundaries(all_paragraphs: list, start_idx: int, end_idx: int) -> dict:
|
||||
"""Find key section boundaries within the NBS content.
|
||||
|
||||
Returns dict with section names and their start indices.
|
||||
"""
|
||||
sections = {}
|
||||
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
text = p.get_text().strip()
|
||||
|
||||
# Introduction bookmark (p13 with "NBS Introduction")
|
||||
if cls == SECTION_HEADING_CLASS and "NBS Introduction" in text:
|
||||
sections["introduction_bookmark"] = i
|
||||
|
||||
# Chapter bookmark (p13 with "NBS: The Value of Devotion") — note but skip
|
||||
elif cls == SECTION_HEADING_CLASS and "NBS:" in text and "Value" in text:
|
||||
sections["chapter_bookmark"] = i
|
||||
|
||||
# First verse bookmark (NBS 1)
|
||||
elif cls == VERSE_BOOKMARK_CLASS:
|
||||
decoded = decode_text(p)
|
||||
vnum = parse_verse_bookmark(decoded)
|
||||
if vnum == 1 and "first_verse" not in sections:
|
||||
sections["first_verse"] = i
|
||||
break
|
||||
|
||||
return sections
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Intro segment extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_intro_segment(
|
||||
all_paragraphs: list, start_idx: int, end_idx: int
|
||||
) -> list[str]:
|
||||
"""Extract paragraphs for the introduction segment.
|
||||
|
||||
Decodes ScaGoudy, preserves HTML formatting, skips empty paragraphs.
|
||||
Includes p48 (body) content and other content classes.
|
||||
"""
|
||||
result = []
|
||||
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
|
||||
if is_empty(p):
|
||||
continue
|
||||
|
||||
# Skip bookmark and header classes
|
||||
if cls in (
|
||||
SECTION_HEADING_CLASS,
|
||||
BOOK_HEADER_CLASS,
|
||||
CHAPTER_NUM_CLASS,
|
||||
CHAPTER_TITLE_CLASS,
|
||||
):
|
||||
continue
|
||||
|
||||
# Include body paragraphs
|
||||
if cls in (BODY_CLASS,):
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
|
||||
# Include quoted verses in introduction
|
||||
elif cls == QUOTED_VERSE_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(f'<div class="quoted-verse">{html}</div>')
|
||||
|
||||
# Include verse translations in introduction
|
||||
elif cls == COMMENTATOR_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verse extraction state machine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_verses(all_paragraphs: list, verses_start: int, verses_end: int) -> list:
|
||||
"""Extract verses from the NBS content using a state machine.
|
||||
|
||||
States: IDLE → VERSE_START → TRANSLITERATION → SYNONYMS →
|
||||
TRANSLATION → POST_TRANSLATION → PURPORT → (back to IDLE)
|
||||
|
||||
Returns list of verse dicts.
|
||||
"""
|
||||
verses = []
|
||||
current_verse = None
|
||||
state = "IDLE"
|
||||
|
||||
for i in range(verses_start, verses_end):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
|
||||
# --- Verse bookmark (p49) ---
|
||||
if cls == VERSE_BOOKMARK_CLASS:
|
||||
# Finalize previous verse if any
|
||||
if current_verse and state != "IDLE":
|
||||
_finalize_verse(current_verse)
|
||||
verses.append(current_verse)
|
||||
|
||||
text = decode_text(p)
|
||||
verse_num = parse_verse_bookmark(text)
|
||||
if verse_num is not None:
|
||||
reference = f"NBS {verse_num}"
|
||||
|
||||
current_verse = {
|
||||
"book_title": "Nārada-bhakti-sūtra",
|
||||
"chapter": 1,
|
||||
"verse": verse_num,
|
||||
"reference": reference,
|
||||
"devanagari": "",
|
||||
"transliteration": "",
|
||||
"_padas": [],
|
||||
"synonyms": [],
|
||||
"translation": "",
|
||||
"purport": [],
|
||||
}
|
||||
state = "VERSE_START"
|
||||
continue
|
||||
|
||||
if not current_verse:
|
||||
continue
|
||||
|
||||
# --- Skip garbled Devanagari lines (p220, p51) ---
|
||||
if cls in (DEVANAGARI_SKIP_CLASS_1, DEVANAGARI_SKIP_CLASS_2):
|
||||
continue
|
||||
|
||||
# --- Verse heading (p50 — "SŪTRA N") - confirms sūtra number ---
|
||||
if cls == VERSE_HEADING_CLASS:
|
||||
inner_text = p.get_text().strip().upper()
|
||||
# This is the "SŪTRA N" heading — skip it
|
||||
if state == "VERSE_START":
|
||||
continue
|
||||
|
||||
# --- TEXT/SŪTRA label (p52) - transition to transliteration ---
|
||||
if cls == TEXT_LABEL_CLASS:
|
||||
state = "TRANSLITERATION"
|
||||
continue
|
||||
|
||||
# --- Pāda transliteration lines (p5) ---
|
||||
if cls == PADA_TRANSLIT_CLASS and state in ("VERSE_START", "TRANSLITERATION"):
|
||||
state = "TRANSLITERATION"
|
||||
line = decode_text(p)
|
||||
# Remove trailing period
|
||||
line = line.rstrip(".")
|
||||
line = line.strip()
|
||||
if line:
|
||||
current_verse["_padas"].append(line)
|
||||
continue
|
||||
|
||||
# --- SYNONYMS header (p165) ---
|
||||
if cls == SYNONYMS_HEADER_CLASS:
|
||||
state = "SYNONYMS"
|
||||
continue
|
||||
|
||||
# --- Synonyms text (p221 for NBS, also check p54 as fallback) ---
|
||||
if cls in (SYNONYM_TEXT_CLASS, SYNONYM_TEXT_CLASS_ALT) and current_verse:
|
||||
state = "SYNONYMS"
|
||||
syn_text = decode_text(p)
|
||||
syn_text = syn_text.strip()
|
||||
parsed = parse_synonyms(syn_text)
|
||||
current_verse["synonyms"].extend(parsed)
|
||||
continue
|
||||
|
||||
# --- TRANSLATION label (p55) — check text content to distinguish from PURPORT ---
|
||||
if cls == TRANSLATION_LABEL_CLASS and current_verse:
|
||||
inner_text = p.get_text().strip().upper()
|
||||
if "TRANSLATION" in inner_text:
|
||||
state = "TRANSLATION"
|
||||
elif "PURPORT" in inner_text:
|
||||
state = "PURPORT"
|
||||
continue
|
||||
|
||||
# --- PURPORT label via p57 (alt class) ---
|
||||
if cls == PURPORT_LABEL_ALT_CLASS and current_verse:
|
||||
inner_text = p.get_text().strip().upper()
|
||||
if "PURPORT" in inner_text:
|
||||
state = "PURPORT"
|
||||
continue
|
||||
elif "TRANSLATION" in inner_text:
|
||||
state = "TRANSLATION"
|
||||
continue
|
||||
|
||||
# --- Translation text (p56) ---
|
||||
if cls == TRANSLATION_TEXT_CLASS and current_verse and state == "TRANSLATION":
|
||||
html = decode_html_content(p)
|
||||
# Remove bold wrapper if present (the whole translation is bold)
|
||||
html = re.sub(r"^<b>(.*)</b>$", r"\1", html, flags=re.DOTALL)
|
||||
html = html.strip()
|
||||
current_verse["translation"] = html
|
||||
state = "POST_TRANSLATION"
|
||||
continue
|
||||
|
||||
# --- Purport paragraphs (p48) ---
|
||||
if (
|
||||
cls == BODY_CLASS
|
||||
and current_verse
|
||||
and state in ("PURPORT", "POST_TRANSLATION")
|
||||
):
|
||||
state = "PURPORT"
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
current_verse["purport"].append(html)
|
||||
continue
|
||||
|
||||
# --- Embedded Sanskrit verse in purport (p53) ---
|
||||
if cls == QUOTED_VERSE_CLASS and current_verse and state == "PURPORT":
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
# Wrap as centered italic div for display
|
||||
current_verse["purport"].append(
|
||||
f'<div class="quoted-verse">{html}</div>'
|
||||
)
|
||||
continue
|
||||
|
||||
# --- Verse translation within purport (p44) ---
|
||||
if cls == COMMENTATOR_CLASS and current_verse and state == "PURPORT":
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
current_verse["purport"].append(html)
|
||||
continue
|
||||
|
||||
# Finalize last verse
|
||||
if current_verse and state != "IDLE":
|
||||
_finalize_verse(current_verse)
|
||||
verses.append(current_verse)
|
||||
|
||||
return verses
|
||||
|
||||
|
||||
def _finalize_verse(verse: dict):
|
||||
"""Build transliteration and Devanagari from pādas. Remove internal fields."""
|
||||
padas = verse.get("_padas", [])
|
||||
|
||||
# Transliteration: one pāda per line, blank sentinels become blank lines
|
||||
trans_lines = []
|
||||
for p in padas:
|
||||
if p == "":
|
||||
trans_lines.append("") # blank line between stanza groups
|
||||
else:
|
||||
trans_lines.append(p)
|
||||
verse["transliteration"] = "\n".join(trans_lines)
|
||||
|
||||
# Devanagari: pair pādas into 2-line verse structure, then convert
|
||||
if padas:
|
||||
paired = pair_padas(padas)
|
||||
verse["devanagari"] = iast_to_devanagari(paired)
|
||||
|
||||
# Clean up internal field
|
||||
if "_padas" in verse:
|
||||
del verse["_padas"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main():
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
html_path = os.path.join(base_dir, "public", "html", "other.html")
|
||||
output_base = os.path.join(base_dir, "lib", "jsons", "books", "nbs")
|
||||
|
||||
if not os.path.exists(html_path):
|
||||
print(f"ERROR: {html_path} not found!")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Reading {html_path}...")
|
||||
with open(html_path, "r", encoding="utf-8") as f:
|
||||
soup = BeautifulSoup(f, "html.parser")
|
||||
|
||||
all_paragraphs = soup.find_all("p")
|
||||
print(f"Total paragraphs in file: {len(all_paragraphs)}")
|
||||
|
||||
# --- Find NBS boundaries ---
|
||||
nbs_start, nbs_end = find_nbs_boundaries(all_paragraphs)
|
||||
print(f"NBS content: paragraphs {nbs_start} to {nbs_end}")
|
||||
|
||||
# --- Find section boundaries ---
|
||||
sections = find_section_boundaries(all_paragraphs, nbs_start, nbs_end)
|
||||
print(f"\nSection boundaries found:")
|
||||
for name, idx in sorted(sections.items(), key=lambda x: x[1]):
|
||||
print(f" [{idx}] {name}")
|
||||
|
||||
intro_bm = sections.get("introduction_bookmark")
|
||||
first_verse = sections.get("first_verse")
|
||||
|
||||
if first_verse is None:
|
||||
print("ERROR: Could not find first verse bookmark (NBS 1)!")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Extract intro segment ---
|
||||
print("\n--- Extracting Intro Segment ---")
|
||||
|
||||
intro_segments = []
|
||||
|
||||
if intro_bm is not None:
|
||||
intro_end = first_verse
|
||||
intro_paras = extract_intro_segment(all_paragraphs, intro_bm, intro_end)
|
||||
intro_segment = {
|
||||
"slug": "introduction",
|
||||
"title": "Introduction",
|
||||
"type": "intro-segment",
|
||||
"book": "nbs",
|
||||
"order": 1,
|
||||
"paragraphs": intro_paras,
|
||||
}
|
||||
intro_segments.append(intro_segment)
|
||||
print(f" Introduction: {len(intro_paras)} paragraphs")
|
||||
|
||||
# --- Extract verses ---
|
||||
print("\n--- Extracting Verses ---")
|
||||
|
||||
verses = extract_verses(all_paragraphs, first_verse, nbs_end)
|
||||
print(f"Extracted {len(verses)} verses (expected 8: Sūtras 1-8)")
|
||||
|
||||
# --- Write intro.json ---
|
||||
intro_meta = {
|
||||
"book": "nbs",
|
||||
"title": "Nārada-bhakti-sūtra",
|
||||
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
|
||||
"year": 1989,
|
||||
"description": "The aphorisms of devotional service by Nārada Muni — translations and purports for sūtras 1–8.",
|
||||
"totalChapters": 1,
|
||||
"chapters": [
|
||||
{
|
||||
"number": 1,
|
||||
"title": "The Value of Devotion",
|
||||
"totalVerses": 8,
|
||||
}
|
||||
],
|
||||
}
|
||||
write_json(intro_meta, os.path.join(output_base, "intro.json"))
|
||||
|
||||
# --- Write intro segments ---
|
||||
print("\n--- Writing Intro Segments ---")
|
||||
for seg in intro_segments:
|
||||
filepath = os.path.join(output_base, "intro", f"{seg['slug']}.json")
|
||||
write_json(seg, filepath)
|
||||
|
||||
# --- Write verse JSON files ---
|
||||
print("\n--- Writing Verse Files ---")
|
||||
for verse in verses:
|
||||
text_num = verse["verse"]
|
||||
filename = f"{str(text_num).zfill(2)}.json"
|
||||
filepath = os.path.join(output_base, "chapters", "01", "verses", filename)
|
||||
write_json(verse, filepath)
|
||||
|
||||
# --- Summary ---
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" EXTRACTION SUMMARY")
|
||||
print(f"{'=' * 60}")
|
||||
print(f" Book: Nārada-bhakti-sūtra")
|
||||
print(f" Intro segments: {len(intro_segments)}")
|
||||
for seg in intro_segments:
|
||||
print(
|
||||
f" {seg['order']}. {seg['title']}: {len(seg['paragraphs'])} paragraphs"
|
||||
)
|
||||
print(f" Verses extracted: {len(verses)}")
|
||||
texts_found = sorted([v["verse"] for v in verses])
|
||||
if texts_found:
|
||||
print(
|
||||
f" Sūtras: {texts_found[0]}-{texts_found[-1]} ({len(texts_found)} total)"
|
||||
)
|
||||
else:
|
||||
print(" Sūtras: None")
|
||||
|
||||
total_purport_paras = sum(len(v["purport"]) for v in verses)
|
||||
total_synonyms = sum(len(v["synonyms"]) for v in verses)
|
||||
print(f" Total purport ¶s: {total_purport_paras}")
|
||||
print(f" Total synonyms: {total_synonyms}")
|
||||
print(f" Output directory: {output_base}/")
|
||||
|
||||
# --- Quality checks ---
|
||||
print(f"\n--- Quality Checks ---")
|
||||
|
||||
# Check all verses have expected fields
|
||||
issues = []
|
||||
for verse in verses:
|
||||
ref = verse["reference"]
|
||||
if not verse["transliteration"]:
|
||||
issues.append(f" {ref}: missing transliteration")
|
||||
if not verse["devanagari"]:
|
||||
issues.append(f" {ref}: missing devanagari")
|
||||
if not verse["synonyms"]:
|
||||
issues.append(f" {ref}: missing synonyms")
|
||||
if not verse["translation"]:
|
||||
issues.append(f" {ref}: missing translation")
|
||||
if not verse["purport"]:
|
||||
issues.append(f" {ref}: missing purport (may be intentional)")
|
||||
|
||||
if issues:
|
||||
print(" ISSUES FOUND:")
|
||||
for issue in issues:
|
||||
print(issue)
|
||||
else:
|
||||
print(
|
||||
" All verses have transliteration, devanagari, synonyms, and translation ✓"
|
||||
)
|
||||
|
||||
# Check expected verse count
|
||||
expected_texts = set(range(1, 9))
|
||||
found_texts = set(v["verse"] for v in verses)
|
||||
missing = expected_texts - found_texts
|
||||
extra = found_texts - expected_texts
|
||||
if missing:
|
||||
print(f" MISSING sūtras: {sorted(missing)}")
|
||||
if extra:
|
||||
print(f" UNEXPECTED sūtras: {sorted(extra)}")
|
||||
if not missing and not extra:
|
||||
print(f" All 8 sūtras found ✓")
|
||||
|
||||
# --- ScaGoudy spot checks ---
|
||||
print(f"\n--- ScaGoudy Spot Checks ---")
|
||||
for verse in verses:
|
||||
ref = verse["reference"]
|
||||
trans = verse["transliteration"]
|
||||
# Check for leftover ScaGoudy characters
|
||||
leftover = set()
|
||||
for ch in trans:
|
||||
if ch in SCAGOUDY_TO_IAST:
|
||||
leftover.add(ch)
|
||||
if leftover:
|
||||
print(
|
||||
f" WARNING: {ref} transliteration has leftover ScaGoudy chars: {leftover}"
|
||||
)
|
||||
else:
|
||||
print(f" {ref}: transliteration clean ✓")
|
||||
|
||||
# --- Samples ---
|
||||
print(f"\n--- Sample: Sūtra 1 (first) ---")
|
||||
t1 = [v for v in verses if v["verse"] == 1]
|
||||
if t1:
|
||||
v = t1[0]
|
||||
print(f" Reference: {v['reference']}")
|
||||
print(f" Transliteration: {v['transliteration'][:100]}...")
|
||||
print(f" Devanagari: {v['devanagari'][:100]}...")
|
||||
print(f" Synonyms: {len(v['synonyms'])} entries")
|
||||
if v["synonyms"]:
|
||||
print(f" First: {v['synonyms'][0]}")
|
||||
print(f" Last: {v['synonyms'][-1]}")
|
||||
trans_preview = re.sub(r"<[^>]+>", "", v["translation"])[:120]
|
||||
print(f" Translation: {trans_preview}...")
|
||||
print(f" Purport ¶s: {len(v['purport'])}")
|
||||
|
||||
print(f"\n--- Sample: Sūtra 4 (middle) ---")
|
||||
t4 = [v for v in verses if v["verse"] == 4]
|
||||
if t4:
|
||||
v = t4[0]
|
||||
print(f" Reference: {v['reference']}")
|
||||
print(f" Transliteration: {v['transliteration'][:100]}...")
|
||||
print(f" Devanagari: {v['devanagari'][:100]}...")
|
||||
print(f" Synonyms: {len(v['synonyms'])} entries")
|
||||
if v["synonyms"]:
|
||||
print(f" First: {v['synonyms'][0]}")
|
||||
print(f" Last: {v['synonyms'][-1]}")
|
||||
trans_preview = re.sub(r"<[^>]+>", "", v["translation"])[:120]
|
||||
print(f" Translation: {trans_preview}...")
|
||||
print(f" Purport ¶s: {len(v['purport'])}")
|
||||
|
||||
print(f"\n--- Sample: Sūtra 8 (last) ---")
|
||||
t8 = [v for v in verses if v["verse"] == 8]
|
||||
if t8:
|
||||
v = t8[0]
|
||||
print(f" Reference: {v['reference']}")
|
||||
print(f" Transliteration: {v['transliteration'][:100]}...")
|
||||
print(f" Devanagari: {v['devanagari'][:100]}...")
|
||||
print(f" Synonyms: {len(v['synonyms'])} entries")
|
||||
if v["synonyms"]:
|
||||
print(f" First: {v['synonyms'][0]}")
|
||||
print(f" Last: {v['synonyms'][-1]}")
|
||||
trans_preview = re.sub(r"<[^>]+>", "", v["translation"])[:120]
|
||||
print(f" Translation: {trans_preview}...")
|
||||
print(f" Purport ¶s: {len(v['purport'])}")
|
||||
|
||||
# --- Intro segment details ---
|
||||
print(f"\n--- Intro Segment Details ---")
|
||||
for seg in intro_segments:
|
||||
print(f" {seg['title']}: {len(seg['paragraphs'])} paragraphs")
|
||||
if seg["paragraphs"]:
|
||||
first_preview = re.sub(r"<[^>]+>", "", seg["paragraphs"][0])[:120]
|
||||
print(f" First ¶: {first_preview}...")
|
||||
last_preview = re.sub(r"<[^>]+>", "", seg["paragraphs"][-1])[:120]
|
||||
print(f" Last ¶: {last_preview}...")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* seed-bs.ts — Push Śrī Brahma-saṁhitā JSON data into MongoDB.
|
||||
*
|
||||
* Uses the native MongoDB driver (same pattern as seed-iso.ts) to avoid
|
||||
* Prisma replica-set write requirements.
|
||||
*
|
||||
* BS is a verse book (like ISO) but also has prose intro segments.
|
||||
* We use volume=0 as the sentinel for "no volumes" on the prose side.
|
||||
*
|
||||
* Collections written:
|
||||
* - book_meta → one doc { book: "bs", data: <intro.json> }
|
||||
* - verses → 62 verse docs (Texts 1-62)
|
||||
* - prose_chapters → 2 intro segment docs (introduction, foreword)
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/seed-bs.ts
|
||||
*/
|
||||
|
||||
import dotenv from "dotenv";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { MongoClient } from "mongodb";
|
||||
|
||||
// Load env
|
||||
dotenv.config({ path: ".env.local" });
|
||||
dotenv.config({ path: ".env" });
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
console.error("❌ DATABASE_URL is not set in .env");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const BASE_DIR = path.resolve(__dirname, "..");
|
||||
const BS_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "bs");
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readJson<T>(filePath: string): T {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert documents one at a time with a small delay every N docs.
|
||||
* Uses insertOne to avoid BSON wire-protocol corruption.
|
||||
*/
|
||||
async function insertBatched(
|
||||
col: ReturnType<ReturnType<MongoClient["db"]>["collection"]>,
|
||||
docs: Record<string, unknown>[],
|
||||
) {
|
||||
for (let i = 0; i < docs.length; i++) {
|
||||
await col.insertOne(docs[i]);
|
||||
if ((i + 1) % 100 === 0) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types for the JSON files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type BsIntroJson = {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalChapters: number;
|
||||
chapters: { number: number; title: string; totalVerses: number }[];
|
||||
};
|
||||
|
||||
type BsVerseJson = {
|
||||
book_title: string;
|
||||
chapter: number;
|
||||
verse: number;
|
||||
reference: string;
|
||||
devanagari: string;
|
||||
transliteration: string;
|
||||
synonyms: { word: string; meanings: string[] }[];
|
||||
translation: string;
|
||||
purport: string[];
|
||||
};
|
||||
|
||||
type BsIntroSegmentJson = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function seedBs(db: ReturnType<MongoClient["db"]>) {
|
||||
const introPath = path.join(BS_DIR, "intro.json");
|
||||
if (!fs.existsSync(introPath)) {
|
||||
console.error("❌ BS intro.json not found at", introPath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const intro = readJson<BsIntroJson>(introPath);
|
||||
|
||||
// ---- 1. Book meta ----
|
||||
const metaCol = db.collection("book_meta");
|
||||
await metaCol.deleteMany({ book: "bs" });
|
||||
await metaCol.insertOne({ book: "bs", data: intro });
|
||||
console.log(" 📖 BS book meta inserted");
|
||||
|
||||
// ---- 2. Verses ----
|
||||
const versesCol = db.collection("verses");
|
||||
await versesCol.deleteMany({ book: "bs" });
|
||||
|
||||
const verseDocs: Record<string, unknown>[] = [];
|
||||
|
||||
// Verse files: chapters/05/verses/01.json through 62.json
|
||||
const versesDir = path.join(BS_DIR, "chapters", "05", "verses");
|
||||
|
||||
if (!fs.existsSync(versesDir)) {
|
||||
console.error("❌ BS verses directory not found at", versesDir);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const verseFiles = fs
|
||||
.readdirSync(versesDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
for (const file of verseFiles) {
|
||||
const filePath = path.join(versesDir, file);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.warn(` ⚠️ Verse file not found, skipping: ${file}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const v = readJson<BsVerseJson>(filePath);
|
||||
|
||||
verseDocs.push({
|
||||
book: "bs",
|
||||
division: null,
|
||||
chapter: v.chapter,
|
||||
verse: v.verse,
|
||||
verseEnd: null,
|
||||
reference: v.reference,
|
||||
bookTitle: v.book_title,
|
||||
devanagari: v.devanagari,
|
||||
bengali: null,
|
||||
transliteration: v.transliteration,
|
||||
synonyms: v.synonyms,
|
||||
translation: v.translation,
|
||||
purport: v.purport,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(` 📜 Verses prepared: ${verseDocs.length}`);
|
||||
console.log(` Inserting ${verseDocs.length} verse documents...`);
|
||||
await insertBatched(versesCol, verseDocs);
|
||||
|
||||
const totalVerses = await versesCol.countDocuments({ book: "bs" });
|
||||
console.log(` ✅ BS verses: ${totalVerses} in MongoDB`);
|
||||
|
||||
// ---- 3. Prose intro segments ----
|
||||
const proseCol = db.collection("prose_chapters");
|
||||
await proseCol.deleteMany({ book: "bs" });
|
||||
|
||||
const proseDocs: Record<string, unknown>[] = [];
|
||||
|
||||
const introSegmentFiles = ["introduction.json", "foreword.json"];
|
||||
|
||||
const introDir = path.join(BS_DIR, "intro");
|
||||
for (const file of introSegmentFiles) {
|
||||
const filePath = path.join(introDir, file);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.warn(` ⚠️ Intro segment not found, skipping: ${file}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const seg = readJson<BsIntroSegmentJson>(filePath);
|
||||
|
||||
proseDocs.push({
|
||||
book: "bs",
|
||||
volume: NO_VOLUME,
|
||||
chapter: null,
|
||||
title: seg.title,
|
||||
type: seg.type || "intro-segment",
|
||||
slug: `intro-${seg.slug}`,
|
||||
order: seg.order,
|
||||
paragraphs: seg.paragraphs,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(` 📄 Intro segments prepared: ${proseDocs.length}`);
|
||||
console.log(` Inserting ${proseDocs.length} prose documents...`);
|
||||
await insertBatched(proseCol, proseDocs);
|
||||
|
||||
const totalProse = await proseCol.countDocuments({ book: "bs" });
|
||||
console.log(` ✅ BS prose: ${totalProse} entries in MongoDB`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
console.log("🌱 Seeding Śrī Brahma-saṁhitā...\n");
|
||||
|
||||
const client = new MongoClient(DATABASE_URL!);
|
||||
await client.connect();
|
||||
console.log("📡 Connected to MongoDB\n");
|
||||
|
||||
const db = client.db();
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
await seedBs(db);
|
||||
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(`\n🎉 Done in ${elapsed}s`);
|
||||
|
||||
await client.close();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("❌ Seed failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* seed-gg.ts — Push Gītār Gān JSON data into MongoDB.
|
||||
*
|
||||
* Uses the native MongoDB driver (same pattern as seed-lcl.ts) to avoid
|
||||
* Prisma replica-set write requirements.
|
||||
*
|
||||
* GG is a prose/poetry book (Bengali poetic edition of Bhagavad-gītā) with no volume structure.
|
||||
* We use volume=0 as the sentinel for "no volumes".
|
||||
*
|
||||
* Collections written:
|
||||
* - book_meta → one doc { book: "gg", data: <intro.json> }
|
||||
* - prose_chapters → intro segments + 18 chapters
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/seed-gg.ts
|
||||
*/
|
||||
|
||||
import dotenv from "dotenv";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { MongoClient } from "mongodb";
|
||||
|
||||
// Load env
|
||||
dotenv.config({ path: ".env.local" });
|
||||
dotenv.config({ path: ".env" });
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
console.error("❌ DATABASE_URL is not set in .env");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const BASE_DIR = path.resolve(__dirname, "..");
|
||||
const GG_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "gg");
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readJson<T>(filePath: string): T {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert documents one at a time with a small delay every N docs.
|
||||
* Uses insertOne to avoid BSON wire-protocol corruption.
|
||||
*/
|
||||
async function insertBatched(
|
||||
col: ReturnType<ReturnType<MongoClient["db"]>["collection"]>,
|
||||
docs: Record<string, unknown>[],
|
||||
) {
|
||||
for (let i = 0; i < docs.length; i++) {
|
||||
await col.insertOne(docs[i]);
|
||||
if ((i + 1) % 100 === 0) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types for the JSON files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type GgIntroJson = {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalChapters: number;
|
||||
chapters: { number: number; title: string }[];
|
||||
};
|
||||
|
||||
type GgIntroSegmentJson = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
type GgChapterJson = {
|
||||
book: string;
|
||||
chapter: number | null;
|
||||
title: string;
|
||||
type: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function seedGg(db: ReturnType<MongoClient["db"]>) {
|
||||
const introPath = path.join(GG_DIR, "intro.json");
|
||||
if (!fs.existsSync(introPath)) {
|
||||
console.error("❌ GG intro.json not found at", introPath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const intro = readJson<GgIntroJson>(introPath);
|
||||
|
||||
// ---- Book meta ----
|
||||
const metaCol = db.collection("book_meta");
|
||||
await metaCol.deleteMany({ book: "gg" });
|
||||
await metaCol.insertOne({ book: "gg", data: intro });
|
||||
console.log(" 📖 GG book meta inserted");
|
||||
|
||||
// ---- Prose chapters collection ----
|
||||
const proseCol = db.collection("prose_chapters");
|
||||
await proseCol.deleteMany({ book: "gg" });
|
||||
|
||||
const docs: Record<string, unknown>[] = [];
|
||||
|
||||
// Intro segments (introduction)
|
||||
const introDir = path.join(GG_DIR, "intro");
|
||||
if (fs.existsSync(introDir)) {
|
||||
const introFiles = fs
|
||||
.readdirSync(introDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
|
||||
for (const file of introFiles) {
|
||||
const seg = readJson<GgIntroSegmentJson>(path.join(introDir, file));
|
||||
docs.push({
|
||||
book: "gg",
|
||||
volume: NO_VOLUME,
|
||||
chapter: null,
|
||||
title: seg.title,
|
||||
type: seg.type || "intro-segment",
|
||||
slug: `intro-${seg.slug || file.replace(".json", "")}`,
|
||||
order: seg.order ?? 0,
|
||||
paragraphs: seg.paragraphs,
|
||||
});
|
||||
}
|
||||
console.log(` 📄 Intro segments: ${introFiles.length}`);
|
||||
}
|
||||
|
||||
// Chapters (01.json – 18.json)
|
||||
const chaptersDir = path.join(GG_DIR, "chapters");
|
||||
if (fs.existsSync(chaptersDir)) {
|
||||
const chapterFiles = fs
|
||||
.readdirSync(chaptersDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
|
||||
for (const file of chapterFiles) {
|
||||
const ch = readJson<GgChapterJson>(path.join(chaptersDir, file));
|
||||
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
|
||||
docs.push({
|
||||
book: "gg",
|
||||
volume: NO_VOLUME,
|
||||
chapter: chNum,
|
||||
title: ch.title,
|
||||
type: ch.type || "chapter",
|
||||
slug: String(chNum),
|
||||
order: 100 + chNum,
|
||||
paragraphs: ch.paragraphs,
|
||||
});
|
||||
}
|
||||
console.log(` 📖 Chapters: ${chapterFiles.length}`);
|
||||
}
|
||||
|
||||
// Insert all docs
|
||||
console.log(`\n Inserting ${docs.length} documents...`);
|
||||
await insertBatched(proseCol, docs);
|
||||
|
||||
const totalDocs = await proseCol.countDocuments({ book: "gg" });
|
||||
console.log(` ✅ GG: ${totalDocs} prose entries in MongoDB`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
console.log("🌱 Seeding Gītār Gān...\n");
|
||||
|
||||
const client = new MongoClient(DATABASE_URL!);
|
||||
await client.connect();
|
||||
console.log("📡 Connected to MongoDB\n");
|
||||
|
||||
const db = client.db();
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
await seedGg(db);
|
||||
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(`\n🎉 Done in ${elapsed}s`);
|
||||
|
||||
await client.close();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("❌ Seed failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* seed-lcl.ts — Push Life Comes from Life JSON data into MongoDB.
|
||||
*
|
||||
* Uses the native MongoDB driver (same pattern as seed-pqpa.ts) to avoid
|
||||
* Prisma replica-set write requirements.
|
||||
*
|
||||
* LCL is a prose/dialogue book (morning walk conversations) with no volume structure.
|
||||
* We use volume=0 as the sentinel for "no volumes".
|
||||
*
|
||||
* Collections written:
|
||||
* - book_meta → one doc { book: "lcl", data: <intro.json> }
|
||||
* - prose_chapters → intro segments + 16 chapters
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/seed-lcl.ts
|
||||
*/
|
||||
|
||||
import dotenv from "dotenv";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { MongoClient } from "mongodb";
|
||||
|
||||
// Load env
|
||||
dotenv.config({ path: ".env.local" });
|
||||
dotenv.config({ path: ".env" });
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
console.error("❌ DATABASE_URL is not set in .env");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const BASE_DIR = path.resolve(__dirname, "..");
|
||||
const LCL_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "lcl");
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readJson<T>(filePath: string): T {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert documents one at a time with a small delay every N docs.
|
||||
* Uses insertOne to avoid BSON wire-protocol corruption.
|
||||
*/
|
||||
async function insertBatched(
|
||||
col: ReturnType<ReturnType<MongoClient["db"]>["collection"]>,
|
||||
docs: Record<string, unknown>[],
|
||||
) {
|
||||
for (let i = 0; i < docs.length; i++) {
|
||||
await col.insertOne(docs[i]);
|
||||
if ((i + 1) % 100 === 0) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types for the JSON files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type LclIntroJson = {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalChapters: number;
|
||||
chapters: { number: number; title: string }[];
|
||||
};
|
||||
|
||||
type LclIntroSegmentJson = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
type LclChapterJson = {
|
||||
book: string;
|
||||
chapter: number | null;
|
||||
title: string;
|
||||
type: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function seedLcl(db: ReturnType<MongoClient["db"]>) {
|
||||
const introPath = path.join(LCL_DIR, "intro.json");
|
||||
if (!fs.existsSync(introPath)) {
|
||||
console.error("❌ LCL intro.json not found at", introPath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const intro = readJson<LclIntroJson>(introPath);
|
||||
|
||||
// ---- Book meta ----
|
||||
const metaCol = db.collection("book_meta");
|
||||
await metaCol.deleteMany({ book: "lcl" });
|
||||
await metaCol.insertOne({ book: "lcl", data: intro });
|
||||
console.log(" 📖 LCL book meta inserted");
|
||||
|
||||
// ---- Prose chapters collection ----
|
||||
const proseCol = db.collection("prose_chapters");
|
||||
await proseCol.deleteMany({ book: "lcl" });
|
||||
|
||||
const docs: Record<string, unknown>[] = [];
|
||||
|
||||
// Intro segments (foreword, introduction, about-the-author)
|
||||
const introDir = path.join(LCL_DIR, "intro");
|
||||
if (fs.existsSync(introDir)) {
|
||||
const introFiles = fs
|
||||
.readdirSync(introDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
|
||||
for (const file of introFiles) {
|
||||
const seg = readJson<LclIntroSegmentJson>(path.join(introDir, file));
|
||||
docs.push({
|
||||
book: "lcl",
|
||||
volume: NO_VOLUME,
|
||||
chapter: null,
|
||||
title: seg.title,
|
||||
type: seg.type || "intro-segment",
|
||||
slug: `intro-${seg.slug || file.replace(".json", "")}`,
|
||||
order: seg.order ?? 0,
|
||||
paragraphs: seg.paragraphs,
|
||||
});
|
||||
}
|
||||
console.log(` 📄 Intro segments: ${introFiles.length}`);
|
||||
}
|
||||
|
||||
// Chapters (01.json – 16.json)
|
||||
const chaptersDir = path.join(LCL_DIR, "chapters");
|
||||
if (fs.existsSync(chaptersDir)) {
|
||||
const chapterFiles = fs
|
||||
.readdirSync(chaptersDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
|
||||
for (const file of chapterFiles) {
|
||||
const ch = readJson<LclChapterJson>(path.join(chaptersDir, file));
|
||||
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
|
||||
docs.push({
|
||||
book: "lcl",
|
||||
volume: NO_VOLUME,
|
||||
chapter: chNum,
|
||||
title: ch.title,
|
||||
type: ch.type || "chapter",
|
||||
slug: String(chNum),
|
||||
order: 100 + chNum,
|
||||
paragraphs: ch.paragraphs,
|
||||
});
|
||||
}
|
||||
console.log(` 📖 Chapters: ${chapterFiles.length}`);
|
||||
}
|
||||
|
||||
// Insert all docs
|
||||
console.log(`\n Inserting ${docs.length} documents...`);
|
||||
await insertBatched(proseCol, docs);
|
||||
|
||||
const totalDocs = await proseCol.countDocuments({ book: "lcl" });
|
||||
console.log(` ✅ LCL: ${totalDocs} prose entries in MongoDB`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
console.log("🌱 Seeding Life Comes from Life...\n");
|
||||
|
||||
const client = new MongoClient(DATABASE_URL!);
|
||||
await client.connect();
|
||||
console.log("📡 Connected to MongoDB\n");
|
||||
|
||||
const db = client.db();
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
await seedLcl(db);
|
||||
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(`\n🎉 Done in ${elapsed}s`);
|
||||
|
||||
await client.close();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("❌ Seed failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* seed-lob.ts — Push Light of the Bhāgavata JSON data into MongoDB.
|
||||
*
|
||||
* Uses the native MongoDB driver (same pattern as seed-lcl.ts) to avoid
|
||||
* Prisma replica-set write requirements.
|
||||
*
|
||||
* LoB is a prose book with 48 "texts" (numbered chapters) plus 4 intro
|
||||
* segments (front-pages, preface, acknowledgement, the-artist).
|
||||
* We use volume=0 as the sentinel for "no volumes".
|
||||
*
|
||||
* Collections written:
|
||||
* - book_meta → one doc { book: "lob", data: <intro.json> }
|
||||
* - prose_chapters → intro segments + 48 chapters
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/seed-lob.ts
|
||||
*/
|
||||
|
||||
import dotenv from "dotenv";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { MongoClient } from "mongodb";
|
||||
|
||||
// Load env
|
||||
dotenv.config({ path: ".env.local" });
|
||||
dotenv.config({ path: ".env" });
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
console.error("❌ DATABASE_URL is not set in .env");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const BASE_DIR = path.resolve(__dirname, "..");
|
||||
const LOB_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "lob");
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readJson<T>(filePath: string): T {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert documents one at a time with a small delay every N docs.
|
||||
* Uses insertOne to avoid BSON wire-protocol corruption.
|
||||
*/
|
||||
async function insertBatched(
|
||||
col: ReturnType<ReturnType<MongoClient["db"]>["collection"]>,
|
||||
docs: Record<string, unknown>[],
|
||||
) {
|
||||
for (let i = 0; i < docs.length; i++) {
|
||||
await col.insertOne(docs[i]);
|
||||
if ((i + 1) % 100 === 0) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types for the JSON files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type LobIntroJson = {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalChapters: number;
|
||||
chapters: { number: number; title: string }[];
|
||||
};
|
||||
|
||||
type LobIntroSegmentJson = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
type LobChapterJson = {
|
||||
book: string;
|
||||
chapter: number | null;
|
||||
title: string;
|
||||
type: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function seedLob(db: ReturnType<MongoClient["db"]>) {
|
||||
const introPath = path.join(LOB_DIR, "intro.json");
|
||||
if (!fs.existsSync(introPath)) {
|
||||
console.error("❌ LoB intro.json not found at", introPath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const intro = readJson<LobIntroJson>(introPath);
|
||||
|
||||
// ---- Book meta ----
|
||||
const metaCol = db.collection("book_meta");
|
||||
await metaCol.deleteMany({ book: "lob" });
|
||||
await metaCol.insertOne({ book: "lob", data: intro });
|
||||
console.log(" 📖 LoB book meta inserted");
|
||||
|
||||
// ---- Prose chapters collection ----
|
||||
const proseCol = db.collection("prose_chapters");
|
||||
await proseCol.deleteMany({ book: "lob" });
|
||||
|
||||
const docs: Record<string, unknown>[] = [];
|
||||
|
||||
// Intro segments (front-pages, preface, acknowledgement, the-artist)
|
||||
const introDir = path.join(LOB_DIR, "intro");
|
||||
if (fs.existsSync(introDir)) {
|
||||
const introFiles = fs
|
||||
.readdirSync(introDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
|
||||
for (const file of introFiles) {
|
||||
const seg = readJson<LobIntroSegmentJson>(path.join(introDir, file));
|
||||
docs.push({
|
||||
book: "lob",
|
||||
volume: NO_VOLUME,
|
||||
chapter: null,
|
||||
title: seg.title,
|
||||
type: seg.type || "intro-segment",
|
||||
slug: `intro-${seg.slug || file.replace(".json", "")}`,
|
||||
order: seg.order ?? 0,
|
||||
paragraphs: seg.paragraphs,
|
||||
});
|
||||
}
|
||||
console.log(` 📄 Intro segments: ${introFiles.length}`);
|
||||
}
|
||||
|
||||
// Chapters (01.json – 48.json)
|
||||
const chaptersDir = path.join(LOB_DIR, "chapters");
|
||||
if (fs.existsSync(chaptersDir)) {
|
||||
const chapterFiles = fs
|
||||
.readdirSync(chaptersDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
|
||||
for (const file of chapterFiles) {
|
||||
const ch = readJson<LobChapterJson>(path.join(chaptersDir, file));
|
||||
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
|
||||
docs.push({
|
||||
book: "lob",
|
||||
volume: NO_VOLUME,
|
||||
chapter: chNum,
|
||||
title: ch.title,
|
||||
type: ch.type || "chapter",
|
||||
slug: String(chNum),
|
||||
order: 100 + chNum,
|
||||
paragraphs: ch.paragraphs,
|
||||
});
|
||||
}
|
||||
console.log(` 📖 Chapters: ${chapterFiles.length}`);
|
||||
}
|
||||
|
||||
// Insert all docs
|
||||
console.log(`\n Inserting ${docs.length} documents...`);
|
||||
await insertBatched(proseCol, docs);
|
||||
|
||||
const totalDocs = await proseCol.countDocuments({ book: "lob" });
|
||||
console.log(` ✅ LoB: ${totalDocs} prose entries in MongoDB`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
console.log("🌱 Seeding Light of the Bhāgavata...\n");
|
||||
|
||||
const client = new MongoClient(DATABASE_URL!);
|
||||
await client.connect();
|
||||
console.log("📡 Connected to MongoDB\n");
|
||||
|
||||
const db = client.db();
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
await seedLob(db);
|
||||
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(`\n🎉 Done in ${elapsed}s`);
|
||||
|
||||
await client.close();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("❌ Seed failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* seed-mm.ts — Push Mukunda-mālā-stotra JSON data into MongoDB.
|
||||
*
|
||||
* Uses the native MongoDB driver (same pattern as seed-bs.ts) to avoid
|
||||
* Prisma replica-set write requirements.
|
||||
*
|
||||
* MM is a verse book (like BS) with a single chapter and prose intro segments.
|
||||
* We use volume=0 as the sentinel for "no volumes" on the prose side.
|
||||
*
|
||||
* Collections written:
|
||||
* - book_meta → one doc { book: "mm", data: <intro.json> }
|
||||
* - verses → 6 verse docs (Texts 1-6)
|
||||
* - prose_chapters → 1 intro segment doc (introduction)
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/seed-mm.ts
|
||||
*/
|
||||
|
||||
import dotenv from "dotenv";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { MongoClient } from "mongodb";
|
||||
|
||||
// Load env
|
||||
dotenv.config({ path: ".env.local" });
|
||||
dotenv.config({ path: ".env" });
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
console.error("❌ DATABASE_URL is not set in .env");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const BASE_DIR = path.resolve(__dirname, "..");
|
||||
const MM_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "mm");
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readJson<T>(filePath: string): T {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert documents one at a time with a small delay every N docs.
|
||||
* Uses insertOne to avoid BSON wire-protocol corruption.
|
||||
*/
|
||||
async function insertBatched(
|
||||
col: ReturnType<ReturnType<MongoClient["db"]>["collection"]>,
|
||||
docs: Record<string, unknown>[],
|
||||
) {
|
||||
for (let i = 0; i < docs.length; i++) {
|
||||
await col.insertOne(docs[i]);
|
||||
if ((i + 1) % 100 === 0) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types for the JSON files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type MmIntroJson = {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalChapters: number;
|
||||
chapters: { number: number; title: string; totalVerses: number }[];
|
||||
};
|
||||
|
||||
type MmVerseJson = {
|
||||
book_title: string;
|
||||
chapter: number;
|
||||
verse: number;
|
||||
reference: string;
|
||||
devanagari: string;
|
||||
transliteration: string;
|
||||
synonyms: { word: string; meanings: string[] }[];
|
||||
translation: string;
|
||||
purport: string[];
|
||||
};
|
||||
|
||||
type MmIntroSegmentJson = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function seedMm(db: ReturnType<MongoClient["db"]>) {
|
||||
const introPath = path.join(MM_DIR, "intro.json");
|
||||
if (!fs.existsSync(introPath)) {
|
||||
console.error("❌ MM intro.json not found at", introPath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const intro = readJson<MmIntroJson>(introPath);
|
||||
|
||||
// ---- 1. Book meta ----
|
||||
const metaCol = db.collection("book_meta");
|
||||
await metaCol.deleteMany({ book: "mm" });
|
||||
await metaCol.insertOne({ book: "mm", data: intro });
|
||||
console.log(" 📖 MM book meta inserted");
|
||||
|
||||
// ---- 2. Verses ----
|
||||
const versesCol = db.collection("verses");
|
||||
await versesCol.deleteMany({ book: "mm" });
|
||||
|
||||
const verseDocs: Record<string, unknown>[] = [];
|
||||
|
||||
// Verse files: chapters/01/verses/01.json through 06.json
|
||||
const versesDir = path.join(MM_DIR, "chapters", "01", "verses");
|
||||
|
||||
if (!fs.existsSync(versesDir)) {
|
||||
console.error("❌ MM verses directory not found at", versesDir);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const verseFiles = fs
|
||||
.readdirSync(versesDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
for (const file of verseFiles) {
|
||||
const filePath = path.join(versesDir, file);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.warn(` ⚠️ Verse file not found, skipping: ${file}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const v = readJson<MmVerseJson>(filePath);
|
||||
|
||||
verseDocs.push({
|
||||
book: "mm",
|
||||
division: null,
|
||||
chapter: v.chapter,
|
||||
verse: v.verse,
|
||||
verseEnd: null,
|
||||
reference: v.reference,
|
||||
bookTitle: v.book_title,
|
||||
devanagari: v.devanagari,
|
||||
bengali: null,
|
||||
transliteration: v.transliteration,
|
||||
synonyms: v.synonyms,
|
||||
translation: v.translation,
|
||||
purport: v.purport,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(` 📜 Verses prepared: ${verseDocs.length}`);
|
||||
console.log(` Inserting ${verseDocs.length} verse documents...`);
|
||||
await insertBatched(versesCol, verseDocs);
|
||||
|
||||
const totalVerses = await versesCol.countDocuments({ book: "mm" });
|
||||
console.log(` ✅ MM verses: ${totalVerses} in MongoDB`);
|
||||
|
||||
// ---- 3. Prose intro segments ----
|
||||
const proseCol = db.collection("prose_chapters");
|
||||
await proseCol.deleteMany({ book: "mm" });
|
||||
|
||||
const proseDocs: Record<string, unknown>[] = [];
|
||||
|
||||
const introSegmentFiles = ["introduction.json"];
|
||||
|
||||
const introDir = path.join(MM_DIR, "intro");
|
||||
for (const file of introSegmentFiles) {
|
||||
const filePath = path.join(introDir, file);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.warn(` ⚠️ Intro segment not found, skipping: ${file}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const seg = readJson<MmIntroSegmentJson>(filePath);
|
||||
|
||||
proseDocs.push({
|
||||
book: "mm",
|
||||
volume: NO_VOLUME,
|
||||
chapter: null,
|
||||
title: seg.title,
|
||||
type: seg.type || "intro-segment",
|
||||
slug: `intro-${seg.slug}`,
|
||||
order: seg.order,
|
||||
paragraphs: seg.paragraphs,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(` 📄 Intro segments prepared: ${proseDocs.length}`);
|
||||
console.log(` Inserting ${proseDocs.length} prose documents...`);
|
||||
await insertBatched(proseCol, proseDocs);
|
||||
|
||||
const totalProse = await proseCol.countDocuments({ book: "mm" });
|
||||
console.log(` ✅ MM prose: ${totalProse} entries in MongoDB`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
console.log("🌱 Seeding Mukunda-mālā-stotra...\n");
|
||||
|
||||
const client = new MongoClient(DATABASE_URL!);
|
||||
await client.connect();
|
||||
console.log("📡 Connected to MongoDB\n");
|
||||
|
||||
const db = client.db();
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
await seedMm(db);
|
||||
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(`\n🎉 Done in ${elapsed}s`);
|
||||
|
||||
await client.close();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("❌ Seed failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* seed-nbs.ts — Push Nārada-bhakti-sūtra JSON data into MongoDB.
|
||||
*
|
||||
* Uses the native MongoDB driver (same pattern as seed-mm.ts) to avoid
|
||||
* Prisma replica-set write requirements.
|
||||
*
|
||||
* NBS is a verse book (like MM) with a single chapter and prose intro segments.
|
||||
* We use volume=0 as the sentinel for "no volumes" on the prose side.
|
||||
*
|
||||
* Collections written:
|
||||
* - book_meta → one doc { book: "nbs", data: <intro.json> }
|
||||
* - verses → 8 verse docs (Sūtras 1-8)
|
||||
* - prose_chapters → 1 intro segment doc (introduction)
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/seed-nbs.ts
|
||||
*/
|
||||
|
||||
import dotenv from "dotenv";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { MongoClient } from "mongodb";
|
||||
|
||||
// Load env
|
||||
dotenv.config({ path: ".env.local" });
|
||||
dotenv.config({ path: ".env" });
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
console.error("❌ DATABASE_URL is not set in .env");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const BASE_DIR = path.resolve(__dirname, "..");
|
||||
const NBS_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "nbs");
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readJson<T>(filePath: string): T {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert documents one at a time with a small delay every N docs.
|
||||
* Uses insertOne to avoid BSON wire-protocol corruption.
|
||||
*/
|
||||
async function insertBatched(
|
||||
col: ReturnType<ReturnType<MongoClient["db"]>["collection"]>,
|
||||
docs: Record<string, unknown>[],
|
||||
) {
|
||||
for (let i = 0; i < docs.length; i++) {
|
||||
await col.insertOne(docs[i]);
|
||||
if ((i + 1) % 100 === 0) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types for the JSON files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type NbsIntroJson = {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalChapters: number;
|
||||
chapters: { number: number; title: string; totalVerses: number }[];
|
||||
};
|
||||
|
||||
type NbsVerseJson = {
|
||||
book_title: string;
|
||||
chapter: number;
|
||||
verse: number;
|
||||
reference: string;
|
||||
devanagari: string;
|
||||
transliteration: string;
|
||||
synonyms: { word: string; meanings: string[] }[];
|
||||
translation: string;
|
||||
purport: string[];
|
||||
};
|
||||
|
||||
type NbsIntroSegmentJson = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function seedNbs(db: ReturnType<MongoClient["db"]>) {
|
||||
const introPath = path.join(NBS_DIR, "intro.json");
|
||||
if (!fs.existsSync(introPath)) {
|
||||
console.error("❌ NBS intro.json not found at", introPath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const intro = readJson<NbsIntroJson>(introPath);
|
||||
|
||||
// ---- 1. Book meta ----
|
||||
const metaCol = db.collection("book_meta");
|
||||
await metaCol.deleteMany({ book: "nbs" });
|
||||
await metaCol.insertOne({ book: "nbs", data: intro });
|
||||
console.log(" 📖 NBS book meta inserted");
|
||||
|
||||
// ---- 2. Verses ----
|
||||
const versesCol = db.collection("verses");
|
||||
await versesCol.deleteMany({ book: "nbs" });
|
||||
|
||||
const verseDocs: Record<string, unknown>[] = [];
|
||||
|
||||
// Verse files: chapters/01/verses/01.json through 08.json
|
||||
const versesDir = path.join(NBS_DIR, "chapters", "01", "verses");
|
||||
|
||||
if (!fs.existsSync(versesDir)) {
|
||||
console.error("❌ NBS verses directory not found at", versesDir);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const verseFiles = fs
|
||||
.readdirSync(versesDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
for (const file of verseFiles) {
|
||||
const filePath = path.join(versesDir, file);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.warn(` ⚠️ Verse file not found, skipping: ${file}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const v = readJson<NbsVerseJson>(filePath);
|
||||
|
||||
verseDocs.push({
|
||||
book: "nbs",
|
||||
division: null,
|
||||
chapter: v.chapter,
|
||||
verse: v.verse,
|
||||
verseEnd: null,
|
||||
reference: v.reference,
|
||||
bookTitle: v.book_title,
|
||||
devanagari: v.devanagari,
|
||||
bengali: null,
|
||||
transliteration: v.transliteration,
|
||||
synonyms: v.synonyms,
|
||||
translation: v.translation,
|
||||
purport: v.purport,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(` 📜 Verses prepared: ${verseDocs.length}`);
|
||||
console.log(` Inserting ${verseDocs.length} verse documents...`);
|
||||
await insertBatched(versesCol, verseDocs);
|
||||
|
||||
const totalVerses = await versesCol.countDocuments({ book: "nbs" });
|
||||
console.log(` ✅ NBS verses: ${totalVerses} in MongoDB`);
|
||||
|
||||
// ---- 3. Prose intro segments ----
|
||||
const proseCol = db.collection("prose_chapters");
|
||||
await proseCol.deleteMany({ book: "nbs" });
|
||||
|
||||
const proseDocs: Record<string, unknown>[] = [];
|
||||
|
||||
const introSegmentFiles = ["introduction.json"];
|
||||
|
||||
const introDir = path.join(NBS_DIR, "intro");
|
||||
for (const file of introSegmentFiles) {
|
||||
const filePath = path.join(introDir, file);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.warn(` ⚠️ Intro segment not found, skipping: ${file}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const seg = readJson<NbsIntroSegmentJson>(filePath);
|
||||
|
||||
proseDocs.push({
|
||||
book: "nbs",
|
||||
volume: NO_VOLUME,
|
||||
chapter: null,
|
||||
title: seg.title,
|
||||
type: seg.type || "intro-segment",
|
||||
slug: `intro-${seg.slug}`,
|
||||
order: seg.order,
|
||||
paragraphs: seg.paragraphs,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(` 📄 Intro segments prepared: ${proseDocs.length}`);
|
||||
console.log(` Inserting ${proseDocs.length} prose documents...`);
|
||||
await insertBatched(proseCol, proseDocs);
|
||||
|
||||
const totalProse = await proseCol.countDocuments({ book: "nbs" });
|
||||
console.log(` ✅ NBS prose: ${totalProse} entries in MongoDB`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
console.log("🌱 Seeding Nārada-bhakti-sūtra...\n");
|
||||
|
||||
const client = new MongoClient(DATABASE_URL!);
|
||||
await client.connect();
|
||||
console.log("📡 Connected to MongoDB\n");
|
||||
|
||||
const db = client.db();
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
await seedNbs(db);
|
||||
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(`\n🎉 Done in ${elapsed}s`);
|
||||
|
||||
await client.close();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("❌ Seed failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* seed-sva.ts — Push Songs of the Vaiṣṇava Ācāryas JSON data into MongoDB.
|
||||
*
|
||||
* Uses the native MongoDB driver (same pattern as seed-lcl.ts) to avoid
|
||||
* Prisma replica-set write requirements.
|
||||
*
|
||||
* SVA is a prose/song book with 4 parts (chapters) plus intro segments.
|
||||
* We use volume=0 as the sentinel for "no volumes".
|
||||
*
|
||||
* Collections written:
|
||||
* - book_meta → one doc { book: "sva", data: <intro.json> }
|
||||
* - prose_chapters → intro segments + 4 chapters
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/seed-sva.ts
|
||||
*/
|
||||
|
||||
import dotenv from "dotenv";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { MongoClient } from "mongodb";
|
||||
|
||||
// Load env
|
||||
dotenv.config({ path: ".env.local" });
|
||||
dotenv.config({ path: ".env" });
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
console.error("❌ DATABASE_URL is not set in .env");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const BASE_DIR = path.resolve(__dirname, "..");
|
||||
const SVA_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "sva");
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readJson<T>(filePath: string): T {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert documents one at a time with a small delay every N docs.
|
||||
* Uses insertOne to avoid BSON wire-protocol corruption.
|
||||
*/
|
||||
async function insertBatched(
|
||||
col: ReturnType<ReturnType<MongoClient["db"]>["collection"]>,
|
||||
docs: Record<string, unknown>[],
|
||||
) {
|
||||
for (let i = 0; i < docs.length; i++) {
|
||||
await col.insertOne(docs[i]);
|
||||
if ((i + 1) % 100 === 0) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types for the JSON files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type SvaIntroJson = {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalChapters: number;
|
||||
chapters: { number: number; title: string }[];
|
||||
};
|
||||
|
||||
type SvaIntroSegmentJson = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
type SvaChapterJson = {
|
||||
book: string;
|
||||
chapter: number | null;
|
||||
title: string;
|
||||
type: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function seedSva(db: ReturnType<MongoClient["db"]>) {
|
||||
const introPath = path.join(SVA_DIR, "intro.json");
|
||||
if (!fs.existsSync(introPath)) {
|
||||
console.error("❌ SVA intro.json not found at", introPath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const intro = readJson<SvaIntroJson>(introPath);
|
||||
|
||||
// ---- Book meta ----
|
||||
const metaCol = db.collection("book_meta");
|
||||
await metaCol.deleteMany({ book: "sva" });
|
||||
await metaCol.insertOne({ book: "sva", data: intro });
|
||||
console.log(" 📖 SVA book meta inserted");
|
||||
|
||||
// ---- Prose chapters collection ----
|
||||
const proseCol = db.collection("prose_chapters");
|
||||
await proseCol.deleteMany({ book: "sva" });
|
||||
|
||||
const docs: Record<string, unknown>[] = [];
|
||||
|
||||
// Intro segments (front-pages, foreword, introduction)
|
||||
const introDir = path.join(SVA_DIR, "intro");
|
||||
if (fs.existsSync(introDir)) {
|
||||
const introFiles = fs
|
||||
.readdirSync(introDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
|
||||
for (const file of introFiles) {
|
||||
const seg = readJson<SvaIntroSegmentJson>(path.join(introDir, file));
|
||||
docs.push({
|
||||
book: "sva",
|
||||
volume: NO_VOLUME,
|
||||
chapter: null,
|
||||
title: seg.title,
|
||||
type: seg.type || "intro-segment",
|
||||
slug: `intro-${seg.slug || file.replace(".json", "")}`,
|
||||
order: seg.order ?? 0,
|
||||
paragraphs: seg.paragraphs,
|
||||
});
|
||||
}
|
||||
console.log(` 📄 Intro segments: ${introFiles.length}`);
|
||||
}
|
||||
|
||||
// Chapters (01.json – 04.json)
|
||||
const chaptersDir = path.join(SVA_DIR, "chapters");
|
||||
if (fs.existsSync(chaptersDir)) {
|
||||
const chapterFiles = fs
|
||||
.readdirSync(chaptersDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
|
||||
for (const file of chapterFiles) {
|
||||
const ch = readJson<SvaChapterJson>(path.join(chaptersDir, file));
|
||||
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
|
||||
docs.push({
|
||||
book: "sva",
|
||||
volume: NO_VOLUME,
|
||||
chapter: chNum,
|
||||
title: ch.title,
|
||||
type: ch.type || "chapter",
|
||||
slug: String(chNum),
|
||||
order: 100 + chNum,
|
||||
paragraphs: ch.paragraphs,
|
||||
});
|
||||
}
|
||||
console.log(` 📖 Chapters: ${chapterFiles.length}`);
|
||||
}
|
||||
|
||||
// Insert all docs
|
||||
console.log(`\n Inserting ${docs.length} documents...`);
|
||||
await insertBatched(proseCol, docs);
|
||||
|
||||
const totalDocs = await proseCol.countDocuments({ book: "sva" });
|
||||
console.log(` ✅ SVA: ${totalDocs} prose entries in MongoDB`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
console.log("🌱 Seeding Songs of the Vaiṣṇava Ācāryas...\n");
|
||||
|
||||
const client = new MongoClient(DATABASE_URL!);
|
||||
await client.connect();
|
||||
console.log("📡 Connected to MongoDB\n");
|
||||
|
||||
const db = client.db();
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
await seedSva(db);
|
||||
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(`\n🎉 Done in ${elapsed}s`);
|
||||
|
||||
await client.close();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("❌ Seed failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* seed-tlk.ts — Push Teachings of Lord Kapila JSON data into MongoDB.
|
||||
*
|
||||
* Uses the native MongoDB driver (same pattern as seed-lcl.ts) to avoid
|
||||
* Prisma replica-set write requirements.
|
||||
*
|
||||
* TLK is a prose book with no volume structure.
|
||||
* We use volume=0 as the sentinel for "no volumes".
|
||||
*
|
||||
* Collections written:
|
||||
* - book_meta → one doc { book: "tlk", data: <intro.json> }
|
||||
* - prose_chapters → intro segments + 18 chapters
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/seed-tlk.ts
|
||||
*/
|
||||
|
||||
import dotenv from "dotenv";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { MongoClient } from "mongodb";
|
||||
|
||||
// Load env
|
||||
dotenv.config({ path: ".env.local" });
|
||||
dotenv.config({ path: ".env" });
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
console.error("❌ DATABASE_URL is not set in .env");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const BASE_DIR = path.resolve(__dirname, "..");
|
||||
const TLK_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "tlk");
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readJson<T>(filePath: string): T {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert documents one at a time with a small delay every N docs.
|
||||
* Uses insertOne to avoid BSON wire-protocol corruption.
|
||||
*/
|
||||
async function insertBatched(
|
||||
col: ReturnType<ReturnType<MongoClient["db"]>["collection"]>,
|
||||
docs: Record<string, unknown>[],
|
||||
) {
|
||||
for (let i = 0; i < docs.length; i++) {
|
||||
await col.insertOne(docs[i]);
|
||||
if ((i + 1) % 100 === 0) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types for the JSON files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TlkIntroJson = {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalChapters: number;
|
||||
chapters: { number: number; title: string }[];
|
||||
};
|
||||
|
||||
type TlkIntroSegmentJson = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
type TlkChapterJson = {
|
||||
book: string;
|
||||
chapter: number | null;
|
||||
title: string;
|
||||
type: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function seedTlk(db: ReturnType<MongoClient["db"]>) {
|
||||
const introPath = path.join(TLK_DIR, "intro.json");
|
||||
if (!fs.existsSync(introPath)) {
|
||||
console.error("❌ TLK intro.json not found at", introPath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const intro = readJson<TlkIntroJson>(introPath);
|
||||
|
||||
// ---- Book meta ----
|
||||
const metaCol = db.collection("book_meta");
|
||||
await metaCol.deleteMany({ book: "tlk" });
|
||||
await metaCol.insertOne({ book: "tlk", data: intro });
|
||||
console.log(" 📖 TLK book meta inserted");
|
||||
|
||||
// ---- Prose chapters collection ----
|
||||
const proseCol = db.collection("prose_chapters");
|
||||
await proseCol.deleteMany({ book: "tlk" });
|
||||
|
||||
const docs: Record<string, unknown>[] = [];
|
||||
|
||||
// Intro segments (front-pages, foreword, about-the-author, credits, back-flap)
|
||||
const introDir = path.join(TLK_DIR, "intro");
|
||||
if (fs.existsSync(introDir)) {
|
||||
const introFiles = fs
|
||||
.readdirSync(introDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
|
||||
for (const file of introFiles) {
|
||||
const seg = readJson<TlkIntroSegmentJson>(path.join(introDir, file));
|
||||
docs.push({
|
||||
book: "tlk",
|
||||
volume: NO_VOLUME,
|
||||
chapter: null,
|
||||
title: seg.title,
|
||||
type: seg.type || "intro-segment",
|
||||
slug: `intro-${seg.slug || file.replace(".json", "")}`,
|
||||
order: seg.order ?? 0,
|
||||
paragraphs: seg.paragraphs,
|
||||
});
|
||||
}
|
||||
console.log(` 📄 Intro segments: ${introFiles.length}`);
|
||||
}
|
||||
|
||||
// Chapters (01.json – 18.json)
|
||||
const chaptersDir = path.join(TLK_DIR, "chapters");
|
||||
if (fs.existsSync(chaptersDir)) {
|
||||
const chapterFiles = fs
|
||||
.readdirSync(chaptersDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
|
||||
for (const file of chapterFiles) {
|
||||
const ch = readJson<TlkChapterJson>(path.join(chaptersDir, file));
|
||||
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
|
||||
docs.push({
|
||||
book: "tlk",
|
||||
volume: NO_VOLUME,
|
||||
chapter: chNum,
|
||||
title: ch.title,
|
||||
type: ch.type || "chapter",
|
||||
slug: String(chNum),
|
||||
order: 100 + chNum,
|
||||
paragraphs: ch.paragraphs,
|
||||
});
|
||||
}
|
||||
console.log(` 📖 Chapters: ${chapterFiles.length}`);
|
||||
}
|
||||
|
||||
// Insert all docs
|
||||
console.log(`\n Inserting ${docs.length} documents...`);
|
||||
await insertBatched(proseCol, docs);
|
||||
|
||||
const totalDocs = await proseCol.countDocuments({ book: "tlk" });
|
||||
console.log(` ✅ TLK: ${totalDocs} prose entries in MongoDB`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
console.log("🌱 Seeding Teachings of Lord Kapila...\n");
|
||||
|
||||
const client = new MongoClient(DATABASE_URL!);
|
||||
await client.connect();
|
||||
console.log("📡 Connected to MongoDB\n");
|
||||
|
||||
const db = client.db();
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
await seedTlk(db);
|
||||
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(`\n🎉 Done in ${elapsed}s`);
|
||||
|
||||
await client.close();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("❌ Seed failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* seed-tqk.ts — Push Teachings of Queen Kuntī JSON data into MongoDB.
|
||||
*
|
||||
* Uses the native MongoDB driver (same pattern as seed-tlk.ts) to avoid
|
||||
* Prisma replica-set write requirements.
|
||||
*
|
||||
* TQK is a prose book with no volume structure.
|
||||
* We use volume=0 as the sentinel for "no volumes".
|
||||
*
|
||||
* Collections written:
|
||||
* - book_meta → one doc { book: "tqk", data: <intro.json> }
|
||||
* - prose_chapters → intro segments + 26 chapters
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/seed-tqk.ts
|
||||
*/
|
||||
|
||||
import dotenv from "dotenv";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { MongoClient } from "mongodb";
|
||||
|
||||
// Load env
|
||||
dotenv.config({ path: ".env.local" });
|
||||
dotenv.config({ path: ".env" });
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
console.error("❌ DATABASE_URL is not set in .env");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const BASE_DIR = path.resolve(__dirname, "..");
|
||||
const TQK_DIR = path.join(BASE_DIR, "lib", "jsons", "books", "tqk");
|
||||
|
||||
/** Volume sentinel for books without volumes. */
|
||||
const NO_VOLUME = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readJson<T>(filePath: string): T {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert documents one at a time with a small delay every N docs.
|
||||
* Uses insertOne to avoid BSON wire-protocol corruption.
|
||||
*/
|
||||
async function insertBatched(
|
||||
col: ReturnType<ReturnType<MongoClient["db"]>["collection"]>,
|
||||
docs: Record<string, unknown>[],
|
||||
) {
|
||||
for (let i = 0; i < docs.length; i++) {
|
||||
await col.insertOne(docs[i]);
|
||||
if ((i + 1) % 100 === 0) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types for the JSON files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TqkIntroJson = {
|
||||
book: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year: number;
|
||||
description: string;
|
||||
totalChapters: number;
|
||||
chapters: { number: number; title: string }[];
|
||||
};
|
||||
|
||||
type TqkIntroSegmentJson = {
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
book: string;
|
||||
order: number;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
type TqkChapterJson = {
|
||||
book: string;
|
||||
chapter: number | null;
|
||||
title: string;
|
||||
type: string;
|
||||
paragraphs: string[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function seedTqk(db: ReturnType<MongoClient["db"]>) {
|
||||
const introPath = path.join(TQK_DIR, "intro.json");
|
||||
if (!fs.existsSync(introPath)) {
|
||||
console.error("❌ TQK intro.json not found at", introPath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const intro = readJson<TqkIntroJson>(introPath);
|
||||
|
||||
// ---- Book meta ----
|
||||
const metaCol = db.collection("book_meta");
|
||||
await metaCol.deleteMany({ book: "tqk" });
|
||||
await metaCol.insertOne({ book: "tqk", data: intro });
|
||||
console.log(" 📖 TQK book meta inserted");
|
||||
|
||||
// ---- Prose chapters collection ----
|
||||
const proseCol = db.collection("prose_chapters");
|
||||
await proseCol.deleteMany({ book: "tqk" });
|
||||
|
||||
const docs: Record<string, unknown>[] = [];
|
||||
|
||||
// Intro segments (front-pages, introduction, back-flap)
|
||||
const introDir = path.join(TQK_DIR, "intro");
|
||||
if (fs.existsSync(introDir)) {
|
||||
const introFiles = fs
|
||||
.readdirSync(introDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
|
||||
for (const file of introFiles) {
|
||||
const seg = readJson<TqkIntroSegmentJson>(path.join(introDir, file));
|
||||
docs.push({
|
||||
book: "tqk",
|
||||
volume: NO_VOLUME,
|
||||
chapter: null,
|
||||
title: seg.title,
|
||||
type: seg.type || "intro-segment",
|
||||
slug: `intro-${seg.slug || file.replace(".json", "")}`,
|
||||
order: seg.order ?? 0,
|
||||
paragraphs: seg.paragraphs,
|
||||
});
|
||||
}
|
||||
console.log(` 📄 Intro segments: ${introFiles.length}`);
|
||||
}
|
||||
|
||||
// Chapters (01.json – 26.json)
|
||||
const chaptersDir = path.join(TQK_DIR, "chapters");
|
||||
if (fs.existsSync(chaptersDir)) {
|
||||
const chapterFiles = fs
|
||||
.readdirSync(chaptersDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort();
|
||||
|
||||
for (const file of chapterFiles) {
|
||||
const ch = readJson<TqkChapterJson>(path.join(chaptersDir, file));
|
||||
const chNum = ch.chapter ?? parseInt(file.replace(".json", ""), 10);
|
||||
docs.push({
|
||||
book: "tqk",
|
||||
volume: NO_VOLUME,
|
||||
chapter: chNum,
|
||||
title: ch.title,
|
||||
type: ch.type || "chapter",
|
||||
slug: String(chNum),
|
||||
order: 100 + chNum,
|
||||
paragraphs: ch.paragraphs,
|
||||
});
|
||||
}
|
||||
console.log(` 📖 Chapters: ${chapterFiles.length}`);
|
||||
}
|
||||
|
||||
// Insert all docs
|
||||
console.log(`\n Inserting ${docs.length} documents...`);
|
||||
await insertBatched(proseCol, docs);
|
||||
|
||||
const totalDocs = await proseCol.countDocuments({ book: "tqk" });
|
||||
console.log(` ✅ TQK: ${totalDocs} prose entries in MongoDB`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
console.log("🌱 Seeding Teachings of Queen Kuntī...\n");
|
||||
|
||||
const client = new MongoClient(DATABASE_URL!);
|
||||
await client.connect();
|
||||
console.log("📡 Connected to MongoDB\n");
|
||||
|
||||
const db = client.db();
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
await seedTqk(db);
|
||||
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(`\n🎉 Done in ${elapsed}s`);
|
||||
|
||||
await client.close();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("❌ Seed failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,858 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract Teachings of Lord Kapila, the Son of Devahūti (1977) from other.html.
|
||||
|
||||
The book has 5 intro/back-matter segments and 18 chapters.
|
||||
|
||||
Intro segments:
|
||||
front-pages (order 1)
|
||||
foreword (order 2)
|
||||
about-the-author (order 97)
|
||||
credits (order 98)
|
||||
back-flap (order 99)
|
||||
|
||||
Output:
|
||||
lib/jsons/books/tlk/intro.json — Book metadata
|
||||
lib/jsons/books/tlk/intro/*.json — Intro segments
|
||||
lib/jsons/books/tlk/chapters/*.json — Chapters (01–18)
|
||||
|
||||
Usage:
|
||||
python3 scripts/tlk_extract.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ScaGoudy → IAST Unicode mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
SCAGOUDY_TO_IAST = {
|
||||
"ä": "ā",
|
||||
"Ä": "Ā",
|
||||
"å": "ṛ",
|
||||
"Å": "Ṛ",
|
||||
"ñ": "ṣ",
|
||||
"Ñ": "Ṣ",
|
||||
"ö": "ṭ",
|
||||
"Ö": "Ṭ",
|
||||
"ë": "ṇ",
|
||||
"Ë": "Ṇ",
|
||||
"ò": "ḍ",
|
||||
"Ò": "Ḍ",
|
||||
"ç": "ś",
|
||||
"Ç": "Ś",
|
||||
"ì": "ṅ",
|
||||
"ï": "ñ",
|
||||
"ü": "ū",
|
||||
"Ü": "Ū",
|
||||
"é": "ī",
|
||||
"É": "Ī",
|
||||
"à": "ṁ",
|
||||
"ù": "ḥ",
|
||||
"è": "ṝ",
|
||||
"û": "ṁl",
|
||||
"ÿ": "ḷ",
|
||||
}
|
||||
|
||||
|
||||
def decode_scagoudy(text: str) -> str:
|
||||
"""Replace ScaGoudy-encoded characters with proper IAST Unicode."""
|
||||
for src, dst in SCAGOUDY_TO_IAST.items():
|
||||
text = text.replace(src, dst)
|
||||
return text
|
||||
|
||||
|
||||
def decode_html_content(tag: Tag) -> str:
|
||||
"""Extract inner HTML from a tag, decode ScaGoudy, preserve formatting tags."""
|
||||
inner = "".join(str(child) for child in tag.children)
|
||||
inner = decode_scagoudy(inner)
|
||||
# Remove span tags with CSS classes (RTF conversion artifacts)
|
||||
inner = re.sub(
|
||||
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, flags=re.DOTALL
|
||||
)
|
||||
# Clean up Apple-tab-span leftovers (tab characters)
|
||||
inner = inner.replace("\t", " ")
|
||||
# Normalize whitespace
|
||||
inner = re.sub(r"\s+", " ", inner).strip()
|
||||
return inner
|
||||
|
||||
|
||||
def decode_text(tag: Tag) -> str:
|
||||
"""Get plain text from a tag with ScaGoudy decoding."""
|
||||
return decode_scagoudy(tag.get_text().strip())
|
||||
|
||||
|
||||
def get_css_class(tag: Tag) -> str:
|
||||
"""Get the first CSS class of a tag, or empty string."""
|
||||
classes = tag.get("class", [])
|
||||
return classes[0] if classes else ""
|
||||
|
||||
|
||||
def is_empty(tag: Tag) -> bool:
|
||||
"""Check if a tag is empty or whitespace-only."""
|
||||
text = tag.get_text().strip()
|
||||
return not text or text == "\xa0"
|
||||
|
||||
|
||||
def is_dash_line(text: str) -> bool:
|
||||
"""Return True if text is only dashes, spaces, and/or whitespace."""
|
||||
cleaned = text.strip()
|
||||
if not cleaned:
|
||||
return False
|
||||
return all(c in "-—–\u2014\u2013 \t" for c in cleaned)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSS class categorization for TLK
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Book title header class (bold, <p class="p12">)
|
||||
BOOK_HEADER_CLASS = "p12"
|
||||
|
||||
# Bookmark class — used for section boundary detection
|
||||
BOOKMARK_CLASS = "p13"
|
||||
|
||||
# Chapter heading (bold, e.g. "CHAPTER ONE\nThe Purpose of Lord Kapila's Advent")
|
||||
CHAPTER_HEADING_CLASS = "p27"
|
||||
|
||||
# Section heading (bold, e.g. "Foreword", "Credits")
|
||||
SECTION_HEADING_CLASS = "p31"
|
||||
|
||||
# Body paragraphs — THE main content class
|
||||
BODY_CLASS = "p6"
|
||||
|
||||
# Sanskrit verse text (italic, multi-line with <br>)
|
||||
SANSKRIT_VERSE_CLASS = "p53"
|
||||
|
||||
# Verse translation/reference text (after Sanskrit verses, mid-purport quotes)
|
||||
VERSE_TRANSLATION_CLASS = "p44"
|
||||
|
||||
# Internal verse bookmark (e.g. "TLK Vs 1", "TLK Vs 2")
|
||||
VERSE_BOOKMARK_CLASS = "p49"
|
||||
|
||||
# TEXT N / TRANSLATION / PURPORT headers (bold)
|
||||
STRUCTURED_HEADER_CLASS = "p55"
|
||||
|
||||
# Translation text (bold — verse translation after TRANSLATION header)
|
||||
TRANSLATION_TEXT_CLASS = "p211"
|
||||
|
||||
# Verse reference (e.g. [Śrīmad-Bhāgavatam 3.25.1])
|
||||
VERSE_REFERENCE_CLASS = "p212"
|
||||
|
||||
# Publication info text
|
||||
PUBLICATION_INFO_CLASS = "p128"
|
||||
|
||||
# Attribution line (e.g. "—The Publishers")
|
||||
ATTRIBUTION_CLASS = "p129"
|
||||
|
||||
# Block quote (bold, testimonials in Credits section)
|
||||
BLOCKQUOTE_CLASS = "p47"
|
||||
|
||||
# Attribution for block quotes
|
||||
QUOTE_ATTRIBUTION_CLASS = "p42"
|
||||
|
||||
# Jacket/design credits
|
||||
JACKET_CREDITS_CLASS = "p3"
|
||||
|
||||
# Classes to skip entirely (empty/break)
|
||||
SKIP_CLASSES = frozenset(["p15"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extraction helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def find_tlk_boundaries(all_paragraphs: list) -> tuple:
|
||||
"""Find the start and end paragraph indices of the TLK content.
|
||||
|
||||
Start: <p class="p12"> containing "Teachings of Lord Kapila"
|
||||
End: next <p class="p12"> which is "Teachings of Queen Kunté" (or similar)
|
||||
"""
|
||||
start_idx = None
|
||||
end_idx = len(all_paragraphs)
|
||||
|
||||
for i, p in enumerate(all_paragraphs):
|
||||
cls = get_css_class(p)
|
||||
text = decode_text(p)
|
||||
|
||||
# Find start
|
||||
if start_idx is None:
|
||||
if cls == BOOK_HEADER_CLASS and "Teachings of Lord Kapila" in text:
|
||||
start_idx = i
|
||||
continue
|
||||
|
||||
# Find end: the next p12 book header after our start
|
||||
if cls == BOOK_HEADER_CLASS:
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
if start_idx is None:
|
||||
print("ERROR: Could not find 'Teachings of Lord Kapila' header in other.html!")
|
||||
sys.exit(1)
|
||||
|
||||
return start_idx, end_idx
|
||||
|
||||
|
||||
def find_all_bookmarks(all_paragraphs: list, start_idx: int, end_idx: int) -> list:
|
||||
"""Find all TLK bookmark tags in the TLK range.
|
||||
|
||||
Bookmarks are p13 class with text starting with "TLK".
|
||||
Returns list of (paragraph_index, bookmark_text).
|
||||
"""
|
||||
bookmarks = []
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
if cls != BOOKMARK_CLASS:
|
||||
continue
|
||||
text = decode_text(p).strip()
|
||||
if text.startswith("TLK"):
|
||||
bookmarks.append((i, text))
|
||||
return bookmarks
|
||||
|
||||
|
||||
def parse_chapter_bookmark(text: str):
|
||||
"""Parse a chapter bookmark like 'TLK 1: The Purpose of Lord Kapila's Advent'.
|
||||
|
||||
Returns (chapter_number, title) or None if not a chapter bookmark.
|
||||
"""
|
||||
m = re.match(r"TLK\s+(\d+):\s+(.+)", text.strip())
|
||||
if m:
|
||||
num = int(m.group(1))
|
||||
title = m.group(2).strip()
|
||||
return num, title
|
||||
return None
|
||||
|
||||
|
||||
def parse_intro_bookmark(text: str):
|
||||
"""Check if this is a front-pages or foreword bookmark.
|
||||
|
||||
Returns the slug or None.
|
||||
"""
|
||||
stripped = text.strip()
|
||||
# "TLK Into" is the front-pages bookmark (note: "Into" not "Intro")
|
||||
if stripped == "TLK Into":
|
||||
return "front-pages"
|
||||
if stripped == "TLK Foreword":
|
||||
return "foreword"
|
||||
return None
|
||||
|
||||
|
||||
def parse_backmatter_bookmark(text: str):
|
||||
"""Check if this is a back-matter bookmark.
|
||||
|
||||
Returns the slug or None.
|
||||
"""
|
||||
stripped = text.strip()
|
||||
if stripped == "TLK The Author":
|
||||
return "about-the-author"
|
||||
if stripped == "TLK Credits":
|
||||
return "credits"
|
||||
if stripped == "TLK Back Flap":
|
||||
return "back-flap"
|
||||
return None
|
||||
|
||||
|
||||
INTRO_SLUG_ORDER = {
|
||||
"front-pages": 1,
|
||||
"foreword": 2,
|
||||
}
|
||||
|
||||
BACKMATTER_SLUG_ORDER = {
|
||||
"about-the-author": 97,
|
||||
"credits": 98,
|
||||
"back-flap": 99,
|
||||
}
|
||||
|
||||
INTRO_SLUG_TITLE = {
|
||||
"front-pages": "Front Pages",
|
||||
"foreword": "Foreword",
|
||||
"about-the-author": "About the Author",
|
||||
"credits": "Credits",
|
||||
"back-flap": "Back Flap",
|
||||
}
|
||||
|
||||
|
||||
def extract_paragraphs(
|
||||
all_paragraphs: list,
|
||||
start_idx: int,
|
||||
end_idx: int,
|
||||
) -> list:
|
||||
"""Extract paragraphs for a section (chapter, intro segment).
|
||||
|
||||
Handles all CSS classes within the TLK content.
|
||||
Handles p53 Sanskrit verses as centered italic divs.
|
||||
Handles structured verse headers (TEXT N, TRANSLATION, PURPORT).
|
||||
"""
|
||||
result = []
|
||||
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
|
||||
# Skip empty paragraphs
|
||||
if is_empty(p):
|
||||
continue
|
||||
|
||||
# Skip noise/break classes
|
||||
if cls in SKIP_CLASSES:
|
||||
continue
|
||||
|
||||
# Skip bookmark tags (p13)
|
||||
if cls == BOOKMARK_CLASS:
|
||||
continue
|
||||
|
||||
# Skip book title header (p12)
|
||||
if cls == BOOK_HEADER_CLASS:
|
||||
continue
|
||||
|
||||
# Skip internal verse bookmarks (p49 containing "TLK Vs")
|
||||
if cls == VERSE_BOOKMARK_CLASS:
|
||||
text = decode_text(p)
|
||||
if "TLK Vs" in text or "TLK" in text:
|
||||
continue
|
||||
|
||||
# Check for dash lines — skip them
|
||||
text = decode_text(p)
|
||||
if is_dash_line(text):
|
||||
continue
|
||||
|
||||
# Sanskrit verse text (p53) — italic, multi-line with <br>
|
||||
if cls == SANSKRIT_VERSE_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
# Strip outer <i> tags, then re-wrap in centered italic div
|
||||
inner = re.sub(r"</?i>", "", html)
|
||||
result.append(f'<div style="text-align:center"><i>{inner}</i></div>')
|
||||
continue
|
||||
|
||||
# Structured headers: TEXT N, TRANSLATION, PURPORT (p55)
|
||||
if cls == STRUCTURED_HEADER_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
# These are already bold in the source; ensure bold wrapping
|
||||
if not html.strip().startswith("<b>"):
|
||||
html = f"<b>{html}</b>"
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Translation text (p211) — bold verse translation
|
||||
if cls == TRANSLATION_TEXT_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
if not html.strip().startswith("<b>"):
|
||||
html = f"<b>{html}</b>"
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Verse reference (p212) — e.g. [Śrīmad-Bhāgavatam 3.25.1]
|
||||
if cls == VERSE_REFERENCE_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Body paragraphs (p6) — the main content class
|
||||
if cls == BODY_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
# Detect Sanskrit verses disguised as body paragraphs:
|
||||
# they start with <i> and contain <br> (multi-line italic = verse)
|
||||
stripped_html = html.strip()
|
||||
if stripped_html.startswith("<i>") and "<br" in stripped_html:
|
||||
inner = re.sub(r"</?i>", "", stripped_html)
|
||||
result.append(
|
||||
f'<div style="text-align:center"><i>{inner}</i></div>'
|
||||
)
|
||||
else:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Verse translation text (p44) — after Sanskrit verses mid-purport
|
||||
if cls == VERSE_TRANSLATION_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Chapter heading (p27) — bold chapter title
|
||||
if cls == CHAPTER_HEADING_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
if not html.strip().startswith("<b>"):
|
||||
html = f"<b>{html}</b>"
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Section heading (p31) — bold section title
|
||||
if cls == SECTION_HEADING_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
if not html.strip().startswith("<b>"):
|
||||
html = f"<b>{html}</b>"
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Publication info (p128)
|
||||
if cls == PUBLICATION_INFO_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Attribution (p129) — e.g. "—The Publishers"
|
||||
if cls == ATTRIBUTION_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Block quotes (p47) — testimonials in Credits section
|
||||
if cls == BLOCKQUOTE_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Quote attribution (p42)
|
||||
if cls == QUOTE_ATTRIBUTION_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Jacket/design credits (p3)
|
||||
if cls == JACKET_CREDITS_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Any other class — try to extract content (be permissive)
|
||||
html = decode_html_content(p)
|
||||
if html and not is_dash_line(text):
|
||||
result.append(html)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main extraction logic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_tlk(soup: BeautifulSoup) -> dict:
|
||||
"""Extract the entire Teachings of Lord Kapila from the parsed HTML.
|
||||
|
||||
Returns a dict with:
|
||||
"intro_segments": list of intro segment dicts
|
||||
"chapters": list of chapter dicts
|
||||
"chapter_listing": list of {number, title} for intro.json
|
||||
"""
|
||||
all_paragraphs = list(soup.find_all("p"))
|
||||
|
||||
# Step 1: Find TLK boundaries
|
||||
tlk_start, tlk_end = find_tlk_boundaries(all_paragraphs)
|
||||
print(f" TLK content: paragraphs {tlk_start} to {tlk_end}")
|
||||
print(f" Total paragraphs in range: {tlk_end - tlk_start}")
|
||||
|
||||
# Step 2: Find all bookmarks
|
||||
bookmarks = find_all_bookmarks(all_paragraphs, tlk_start, tlk_end)
|
||||
print(f" Found {len(bookmarks)} TLK bookmarks")
|
||||
for pos, text in bookmarks:
|
||||
print(f" Para ~{pos}: {text}")
|
||||
|
||||
# Step 3: Categorize bookmarks
|
||||
intro_bookmarks = {} # slug -> paragraph index
|
||||
backmatter_bookmarks = {} # slug -> paragraph index
|
||||
chapter_bookmarks = [] # (para_idx, chapter_num, title)
|
||||
|
||||
for pos, text in bookmarks:
|
||||
# Check chapter
|
||||
ch = parse_chapter_bookmark(text)
|
||||
if ch:
|
||||
chapter_bookmarks.append((pos, ch[0], ch[1]))
|
||||
continue
|
||||
|
||||
# Check intro (front-pages, foreword)
|
||||
intro_slug = parse_intro_bookmark(text)
|
||||
if intro_slug:
|
||||
intro_bookmarks[intro_slug] = pos
|
||||
continue
|
||||
|
||||
# Check back matter (about-the-author, credits, back-flap)
|
||||
bm_slug = parse_backmatter_bookmark(text)
|
||||
if bm_slug:
|
||||
backmatter_bookmarks[bm_slug] = pos
|
||||
continue
|
||||
|
||||
print(f" WARNING: Unrecognized bookmark: {text}")
|
||||
|
||||
print(f" Intro bookmarks: {list(intro_bookmarks.keys())}")
|
||||
print(f" Chapter bookmarks: {len(chapter_bookmarks)}")
|
||||
print(f" Back matter bookmarks: {list(backmatter_bookmarks.keys())}")
|
||||
|
||||
# Build an ordered list of all section start positions for boundary computation
|
||||
all_section_starts = sorted(
|
||||
list(intro_bookmarks.values())
|
||||
+ [pos for pos, _, _ in chapter_bookmarks]
|
||||
+ list(backmatter_bookmarks.values())
|
||||
)
|
||||
|
||||
def next_section_after(pos):
|
||||
"""Return the next section start after `pos`, or tlk_end."""
|
||||
for s in all_section_starts:
|
||||
if s > pos:
|
||||
return s
|
||||
return tlk_end
|
||||
|
||||
# Step 4: Extract intro segments (front-pages, foreword)
|
||||
intro_segments = []
|
||||
for slug in ["front-pages", "foreword"]:
|
||||
if slug in intro_bookmarks:
|
||||
bm_pos = intro_bookmarks[slug]
|
||||
content_start = bm_pos + 1
|
||||
content_end = next_section_after(bm_pos)
|
||||
paras = extract_paragraphs(all_paragraphs, content_start, content_end)
|
||||
seg = {
|
||||
"slug": slug,
|
||||
"title": INTRO_SLUG_TITLE[slug],
|
||||
"type": "intro-segment",
|
||||
"book": "tlk",
|
||||
"order": INTRO_SLUG_ORDER[slug],
|
||||
"paragraphs": paras,
|
||||
}
|
||||
intro_segments.append(seg)
|
||||
|
||||
# Step 5: Extract chapters 1–18
|
||||
chapters = []
|
||||
for idx, (ch_pos, ch_num, ch_title) in enumerate(chapter_bookmarks):
|
||||
content_start = ch_pos + 1
|
||||
content_end = next_section_after(ch_pos)
|
||||
|
||||
paragraphs = extract_paragraphs(all_paragraphs, content_start, content_end)
|
||||
|
||||
ch = {
|
||||
"book": "tlk",
|
||||
"chapter": ch_num,
|
||||
"title": ch_title,
|
||||
"type": "chapter",
|
||||
"paragraphs": paragraphs,
|
||||
}
|
||||
chapters.append(ch)
|
||||
|
||||
# Step 6: Extract back matter segments (about-the-author, credits, back-flap)
|
||||
backmatter_segments = []
|
||||
for slug in ["about-the-author", "credits", "back-flap"]:
|
||||
if slug in backmatter_bookmarks:
|
||||
bm_pos = backmatter_bookmarks[slug]
|
||||
content_start = bm_pos + 1
|
||||
content_end = next_section_after(bm_pos)
|
||||
paras = extract_paragraphs(all_paragraphs, content_start, content_end)
|
||||
seg = {
|
||||
"slug": slug,
|
||||
"title": INTRO_SLUG_TITLE[slug],
|
||||
"type": "intro-segment",
|
||||
"book": "tlk",
|
||||
"order": BACKMATTER_SLUG_ORDER[slug],
|
||||
"paragraphs": paras,
|
||||
}
|
||||
backmatter_segments.append(seg)
|
||||
|
||||
# Combine all intro segments (front + back matter)
|
||||
all_intro_segments = intro_segments + backmatter_segments
|
||||
|
||||
# Step 7: Build chapter listing for intro.json
|
||||
chapter_listing = [
|
||||
{"number": ch["chapter"], "title": ch["title"]} for ch in chapters
|
||||
]
|
||||
|
||||
return {
|
||||
"intro_segments": all_intro_segments,
|
||||
"chapters": chapters,
|
||||
"chapter_listing": chapter_listing,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def write_json(data, filepath):
|
||||
"""Write data as formatted JSON to filepath, creating directories as needed."""
|
||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def main():
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
html_path = os.path.join(base_dir, "public", "html", "other.html")
|
||||
output_base = os.path.join(base_dir, "lib", "jsons", "books", "tlk")
|
||||
|
||||
if not os.path.exists(html_path):
|
||||
print(f"ERROR: {html_path} not found!")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Reading {html_path}...")
|
||||
with open(html_path, "r", encoding="utf-8") as f:
|
||||
soup = BeautifulSoup(f, "html.parser")
|
||||
|
||||
print("Extracting Teachings of Lord Kapila, the Son of Devahūti (1977)...\n")
|
||||
result = extract_tlk(soup)
|
||||
|
||||
intro_segments = result["intro_segments"]
|
||||
chapters = result["chapters"]
|
||||
chapter_listing = result["chapter_listing"]
|
||||
|
||||
# --- Write intro.json ---
|
||||
intro_meta = {
|
||||
"book": "tlk",
|
||||
"title": "Teachings of Lord Kapila, the Son of Devahūti",
|
||||
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
|
||||
"year": 1977,
|
||||
"description": "Lord Kapila's teachings on Sāṅkhya philosophy and bhakti-yoga to His mother Devahūti.",
|
||||
"totalChapters": 18,
|
||||
"chapters": chapter_listing,
|
||||
}
|
||||
intro_path = os.path.join(output_base, "intro.json")
|
||||
write_json(intro_meta, intro_path)
|
||||
print(f"\n Written: {intro_path}")
|
||||
|
||||
# --- Write intro segments ---
|
||||
print(f"\n--- Intro / Back-Matter Segments ---")
|
||||
for seg in intro_segments:
|
||||
filepath = os.path.join(output_base, "intro", f"{seg['slug']}.json")
|
||||
write_json(seg, filepath)
|
||||
print(
|
||||
f" {seg['order']:>2}. {seg['title']}: {len(seg['paragraphs'])} paragraphs → {filepath}"
|
||||
)
|
||||
|
||||
# --- Write chapters ---
|
||||
print(f"\n--- Chapters ---")
|
||||
for ch in chapters:
|
||||
ch_num_str = str(ch["chapter"]).zfill(2)
|
||||
filepath = os.path.join(output_base, "chapters", f"{ch_num_str}.json")
|
||||
write_json(ch, filepath)
|
||||
print(
|
||||
f" Ch {ch['chapter']:>2}: {len(ch['paragraphs']):>3} paragraphs — "
|
||||
f"{ch['title']}"
|
||||
)
|
||||
|
||||
# --- Summary ---
|
||||
total_intro_paras = sum(len(s["paragraphs"]) for s in intro_segments)
|
||||
total_chapter_paras = sum(len(ch["paragraphs"]) for ch in chapters)
|
||||
total_paras = total_intro_paras + total_chapter_paras
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" EXTRACTION SUMMARY")
|
||||
print(f"{'=' * 60}")
|
||||
print(f" Intro/back-matter segments: {len(intro_segments)}")
|
||||
for seg in intro_segments:
|
||||
print(
|
||||
f" {seg['order']:>2}. {seg['title']}: {len(seg['paragraphs'])} paragraphs"
|
||||
)
|
||||
print(f" Chapters: {len(chapters)}")
|
||||
print(f" Total paragraphs: {total_paras}")
|
||||
print(f" Intro/back: {total_intro_paras}")
|
||||
print(f" Chapters: {total_chapter_paras}")
|
||||
if chapters:
|
||||
print(f" Avg paras/chapter: {total_chapter_paras / len(chapters):.1f}")
|
||||
print(f" Output directory: {output_base}/")
|
||||
|
||||
# --- Samples ---
|
||||
print(f"\n--- Sample: Chapter 1 ---")
|
||||
if chapters:
|
||||
ch1 = chapters[0]
|
||||
for i, p in enumerate(ch1["paragraphs"][:5]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:120]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
if len(chapters) >= 9:
|
||||
print(f"\n--- Sample: Chapter 9 ---")
|
||||
ch9 = [c for c in chapters if c["chapter"] == 9]
|
||||
if ch9:
|
||||
for i, p in enumerate(ch9[0]["paragraphs"][:3]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:120]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
if len(chapters) >= 18:
|
||||
print(f"\n--- Sample: Chapter 18 (last) ---")
|
||||
last = chapters[-1]
|
||||
for i, p in enumerate(last["paragraphs"][:3]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:120]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
# --- Quality Checks ---
|
||||
print(f"\n--- Quality Checks ---")
|
||||
|
||||
# Check all 18 chapters present
|
||||
expected_chapters = set(range(1, 19))
|
||||
actual_chapters = set(ch["chapter"] for ch in chapters)
|
||||
missing = expected_chapters - actual_chapters
|
||||
extra = actual_chapters - expected_chapters
|
||||
if missing:
|
||||
print(f" ⚠ Missing chapters: {sorted(missing)}")
|
||||
if extra:
|
||||
print(f" ⚠ Extra chapters: {sorted(extra)}")
|
||||
if not missing and not extra:
|
||||
print(f" ✅ All 18 chapters present")
|
||||
|
||||
# Check intro segments
|
||||
expected_intros = {
|
||||
"front-pages",
|
||||
"foreword",
|
||||
"about-the-author",
|
||||
"credits",
|
||||
"back-flap",
|
||||
}
|
||||
actual_intros = {s["slug"] for s in intro_segments}
|
||||
missing_intros = expected_intros - actual_intros
|
||||
extra_intros = actual_intros - expected_intros
|
||||
if missing_intros:
|
||||
print(f" ⚠ Missing intro segments: {missing_intros}")
|
||||
if extra_intros:
|
||||
print(f" ⚠ Extra intro segments: {extra_intros}")
|
||||
if not missing_intros and not extra_intros:
|
||||
print(f" ✅ All 5 intro/back-matter segments present")
|
||||
|
||||
# Check empty chapters
|
||||
empty_chapters = [ch["chapter"] for ch in chapters if len(ch["paragraphs"]) == 0]
|
||||
if empty_chapters:
|
||||
print(f" ⚠ Empty chapters (0 paragraphs): {empty_chapters}")
|
||||
else:
|
||||
print(f" ✅ No empty chapters")
|
||||
|
||||
# Check empty intro segments
|
||||
empty_intros = [s["slug"] for s in intro_segments if len(s["paragraphs"]) == 0]
|
||||
if empty_intros:
|
||||
print(f" ⚠ Empty intro segments (0 paragraphs): {empty_intros}")
|
||||
else:
|
||||
print(f" ✅ No empty intro segments")
|
||||
|
||||
# Check ScaGoudy decoding — look for common encoded characters
|
||||
encoded_chars = set("äåöëòçüéàùè")
|
||||
suspect_count = 0
|
||||
all_sections = chapters + intro_segments
|
||||
for section in all_sections:
|
||||
for p in section["paragraphs"]:
|
||||
plain = re.sub(r"<[^>]+>", "", p)
|
||||
for c in encoded_chars:
|
||||
if c in plain:
|
||||
suspect_count += 1
|
||||
break
|
||||
if suspect_count == 0:
|
||||
print(f" ✅ No suspected undecoded ScaGoudy characters found")
|
||||
else:
|
||||
print(
|
||||
f" ℹ {suspect_count} paragraphs contain characters that may be undecoded ScaGoudy"
|
||||
)
|
||||
|
||||
# Check verse blocks (centered italic divs)
|
||||
verse_block_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if '<div style="text-align:center"><i>' in p:
|
||||
verse_block_count += 1
|
||||
print(f" ℹ {verse_block_count} Sanskrit verse blocks in chapters")
|
||||
|
||||
# Count structured verse headers (TEXT N, TRANSLATION, PURPORT)
|
||||
text_n_count = 0
|
||||
translation_header_count = 0
|
||||
purport_header_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
plain = re.sub(r"<[^>]+>", "", p).strip()
|
||||
if re.match(r"TEXT\s+\d+", plain):
|
||||
text_n_count += 1
|
||||
elif plain == "TRANSLATION":
|
||||
translation_header_count += 1
|
||||
elif plain == "PURPORT":
|
||||
purport_header_count += 1
|
||||
print(f" ℹ TEXT N headers: {text_n_count}")
|
||||
print(f" ℹ TRANSLATION headers: {translation_header_count}")
|
||||
print(f" ℹ PURPORT headers: {purport_header_count}")
|
||||
|
||||
# ScaGoudy decoding spot check
|
||||
print(f"\n--- ScaGoudy Decoding Spot Check ---")
|
||||
if chapters:
|
||||
ch1_text = " ".join(chapters[0]["paragraphs"][:20])
|
||||
if "Prabhupāda" in ch1_text:
|
||||
print(f" ✅ 'Prabhupāda' correctly decoded")
|
||||
elif "Prabhupäda" in ch1_text:
|
||||
print(f" ⚠ ScaGoudy decoding incomplete for 'Prabhupäda'")
|
||||
else:
|
||||
print(f" ℹ 'Prabhupāda' not found in Chapter 1 first 20 paragraphs")
|
||||
|
||||
if "Kṛṣṇa" in ch1_text:
|
||||
print(f" ✅ 'Kṛṣṇa' correctly decoded in Chapter 1")
|
||||
elif "Kåñëa" in ch1_text:
|
||||
print(f" ⚠ 'Kåñëa' still encoded — ScaGoudy decoding may have failed!")
|
||||
else:
|
||||
print(
|
||||
f" ℹ 'Kṛṣṇa' not found in Chapter 1 first 20 paragraphs (may be normal)"
|
||||
)
|
||||
|
||||
# Check for Kapila
|
||||
if "Kapila" in ch1_text:
|
||||
print(f" ✅ 'Kapila' found in Chapter 1")
|
||||
|
||||
# Check for Devahūti
|
||||
if "Devahūti" in ch1_text:
|
||||
print(f" ✅ 'Devahūti' correctly decoded")
|
||||
elif "Devahüti" in ch1_text:
|
||||
print(f" ⚠ 'Devahüti' still encoded")
|
||||
|
||||
# Check for Sāṅkhya
|
||||
if "Sāṅkhya" in ch1_text or "sāṅkhya" in ch1_text or "Sāṇkhya" in ch1_text:
|
||||
print(f" ✅ 'Sāṅkhya' correctly decoded")
|
||||
|
||||
# Sample verse blocks
|
||||
print(f"\n--- Sample Sanskrit Verse Blocks ---")
|
||||
shown = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if '<div style="text-align:center"><i>' in p:
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:120]
|
||||
print(f" Ch {ch['chapter']}: {preview}...")
|
||||
shown += 1
|
||||
if shown >= 3:
|
||||
break
|
||||
if shown >= 3:
|
||||
break
|
||||
|
||||
# Sample structured verse sections (TEXT N + TRANSLATION)
|
||||
print(f"\n--- Sample Structured Verse Sections ---")
|
||||
shown = 0
|
||||
for ch in chapters[:3]:
|
||||
for i, p in enumerate(ch["paragraphs"]):
|
||||
plain = re.sub(r"<[^>]+>", "", p).strip()
|
||||
if re.match(r"TEXT\s+\d+", plain) and shown < 3:
|
||||
# Show TEXT header and next few paragraphs
|
||||
print(f" Ch {ch['chapter']}:")
|
||||
for j in range(i, min(i + 5, len(ch["paragraphs"]))):
|
||||
pj_preview = re.sub(r"<[^>]+>", "", ch["paragraphs"][j])[:100]
|
||||
print(f" [{j}] {pj_preview}...")
|
||||
shown += 1
|
||||
print()
|
||||
if shown >= 3:
|
||||
break
|
||||
|
||||
# Check intro segments content
|
||||
print(f"--- Intro Segment Samples ---")
|
||||
for seg in intro_segments:
|
||||
print(f"\n {seg['title']} ({seg['slug']}, order {seg['order']}):")
|
||||
for i, p in enumerate(seg["paragraphs"][:2]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:120]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
print(f"\nDone! ✨")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,864 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract Teachings of Queen Kuntī (1978) from other.html.
|
||||
|
||||
The book has 3 intro/back-matter segments and 26 chapters.
|
||||
|
||||
Intro segments:
|
||||
front-pages (order 1)
|
||||
introduction (order 2)
|
||||
back-flap (order 99)
|
||||
|
||||
Output:
|
||||
lib/jsons/books/tqk/intro.json — Book metadata
|
||||
lib/jsons/books/tqk/intro/*.json — Intro segments
|
||||
lib/jsons/books/tqk/chapters/*.json — Chapters (01–26)
|
||||
|
||||
Usage:
|
||||
python3 scripts/tqk_extract.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ScaGoudy → IAST Unicode mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
SCAGOUDY_TO_IAST = {
|
||||
"ä": "ā",
|
||||
"Ä": "Ā",
|
||||
"å": "ṛ",
|
||||
"Å": "Ṛ",
|
||||
"ñ": "ṣ",
|
||||
"Ñ": "Ṣ",
|
||||
"ö": "ṭ",
|
||||
"Ö": "Ṭ",
|
||||
"ë": "ṇ",
|
||||
"Ë": "Ṇ",
|
||||
"ò": "ḍ",
|
||||
"Ò": "Ḍ",
|
||||
"ç": "ś",
|
||||
"Ç": "Ś",
|
||||
"ì": "ṅ",
|
||||
"ï": "ñ",
|
||||
"ü": "ū",
|
||||
"Ü": "Ū",
|
||||
"é": "ī",
|
||||
"É": "Ī",
|
||||
"à": "ṁ",
|
||||
"ù": "ḥ",
|
||||
"è": "ṝ",
|
||||
"û": "ṁl",
|
||||
"ÿ": "ḷ",
|
||||
}
|
||||
|
||||
|
||||
def decode_scagoudy(text: str) -> str:
|
||||
"""Replace ScaGoudy-encoded characters with proper IAST Unicode."""
|
||||
for src, dst in SCAGOUDY_TO_IAST.items():
|
||||
text = text.replace(src, dst)
|
||||
return text
|
||||
|
||||
|
||||
def decode_html_content(tag: Tag) -> str:
|
||||
"""Extract inner HTML from a tag, decode ScaGoudy, preserve formatting tags."""
|
||||
inner = "".join(str(child) for child in tag.children)
|
||||
inner = decode_scagoudy(inner)
|
||||
# Remove span tags with CSS classes (RTF conversion artifacts)
|
||||
inner = re.sub(
|
||||
r'<span[^>]*class="[^"]*"[^>]*>(.*?)</span>', r"\1", inner, flags=re.DOTALL
|
||||
)
|
||||
# Clean up Apple-tab-span leftovers (tab characters)
|
||||
inner = inner.replace("\t", " ")
|
||||
# Normalize whitespace
|
||||
inner = re.sub(r"\s+", " ", inner).strip()
|
||||
return inner
|
||||
|
||||
|
||||
def decode_text(tag: Tag) -> str:
|
||||
"""Get plain text from a tag with ScaGoudy decoding."""
|
||||
return decode_scagoudy(tag.get_text().strip())
|
||||
|
||||
|
||||
def get_css_class(tag: Tag) -> str:
|
||||
"""Get the first CSS class of a tag, or empty string."""
|
||||
classes = tag.get("class", [])
|
||||
return classes[0] if classes else ""
|
||||
|
||||
|
||||
def is_empty(tag: Tag) -> bool:
|
||||
"""Check if a tag is empty or whitespace-only."""
|
||||
text = tag.get_text().strip()
|
||||
return not text or text == "\xa0"
|
||||
|
||||
|
||||
def is_dash_line(text: str) -> bool:
|
||||
"""Return True if text is only dashes, spaces, and/or whitespace."""
|
||||
cleaned = text.strip()
|
||||
if not cleaned:
|
||||
return False
|
||||
return all(c in "-—–\u2014\u2013 \t" for c in cleaned)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSS class categorization for TQK
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Book title header class (bold, <p class="p12">)
|
||||
BOOK_HEADER_CLASS = "p12"
|
||||
|
||||
# Bookmark class — used for section boundary detection
|
||||
BOOKMARK_CLASS = "p13"
|
||||
|
||||
# Chapter heading (bold, e.g. "1. The Original Person")
|
||||
CHAPTER_HEADING_CLASS = "p27"
|
||||
|
||||
# Section heading (bold, e.g. "Front flap", "Introduction", "Back flap")
|
||||
SECTION_HEADING_CLASS = "p31"
|
||||
|
||||
# Body paragraphs — THE main content class
|
||||
BODY_CLASS = "p6"
|
||||
|
||||
# Sanskrit verse text (italic, multi-line with <br>)
|
||||
SANSKRIT_VERSE_CLASS = "p53"
|
||||
|
||||
# Verse translation/reference text (after Sanskrit verses, mid-purport quotes)
|
||||
VERSE_TRANSLATION_CLASS = "p44"
|
||||
|
||||
# TEXT N / TRANSLATION / PURPORT headers (bold)
|
||||
STRUCTURED_HEADER_CLASS = "p55"
|
||||
|
||||
# Translation text (bold — verse translation after TRANSLATION header)
|
||||
TRANSLATION_TEXT_CLASS = "p211"
|
||||
|
||||
# Verse reference (e.g. —Śrīmad-Bhāgavatam 1.8.18)
|
||||
VERSE_REFERENCE_CLASS_212 = "p212"
|
||||
VERSE_REFERENCE_CLASS_216 = "p216"
|
||||
|
||||
# Short text lines
|
||||
SHORT_TEXT_CLASS = "p118"
|
||||
|
||||
# Italic/centered text (e.g. block quotes in introduction)
|
||||
CENTERED_ITALIC_CLASS = "p25"
|
||||
BLOCK_QUOTE_CLASS_23 = "p23"
|
||||
|
||||
# Publication info text
|
||||
PUBLICATION_INFO_CLASS_214 = "p214"
|
||||
PUBLICATION_INFO_CLASS_215 = "p215"
|
||||
|
||||
# Testimonial quotes (back flap)
|
||||
TESTIMONIAL_QUOTE_CLASS = "p18"
|
||||
|
||||
# Testimonial attribution (back flap)
|
||||
TESTIMONIAL_ATTRIBUTION_CLASS = "p147"
|
||||
|
||||
# Jacket/design credits
|
||||
JACKET_CREDITS_CLASS_3 = "p3"
|
||||
JACKET_CREDITS_CLASS_21 = "p21"
|
||||
|
||||
# Glossary heading (skip)
|
||||
GLOSSARY_CLASS = "p19"
|
||||
|
||||
# Classes to skip entirely (empty/break)
|
||||
SKIP_CLASSES = frozenset(["p15"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extraction helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def find_tqk_boundaries(all_paragraphs: list) -> tuple:
|
||||
"""Find the start and end paragraph indices of the TQK content.
|
||||
|
||||
Start: <p class="p12"> containing "Teachings of Queen Kunté"
|
||||
End: next <p class="p12"> after TQK start (which is "Renunciation Through Wisdom")
|
||||
or end of file if TQK is the last book.
|
||||
"""
|
||||
start_idx = None
|
||||
end_idx = len(all_paragraphs)
|
||||
|
||||
for i, p in enumerate(all_paragraphs):
|
||||
cls = get_css_class(p)
|
||||
text = decode_text(p)
|
||||
|
||||
# Find start
|
||||
if start_idx is None:
|
||||
if cls == BOOK_HEADER_CLASS and "Teachings of Queen Kunt" in text:
|
||||
start_idx = i
|
||||
continue
|
||||
|
||||
# Find end: the next p12 book header after our start
|
||||
if cls == BOOK_HEADER_CLASS:
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
if start_idx is None:
|
||||
print("ERROR: Could not find 'Teachings of Queen Kunté' header in other.html!")
|
||||
sys.exit(1)
|
||||
|
||||
return start_idx, end_idx
|
||||
|
||||
|
||||
def find_all_bookmarks(all_paragraphs: list, start_idx: int, end_idx: int) -> list:
|
||||
"""Find all TQK bookmark tags in the TQK range.
|
||||
|
||||
Bookmarks are p13 class with text starting with "TQK".
|
||||
Returns list of (paragraph_index, bookmark_text).
|
||||
"""
|
||||
bookmarks = []
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
if cls != BOOKMARK_CLASS:
|
||||
continue
|
||||
text = decode_text(p).strip()
|
||||
if text.startswith("TQK"):
|
||||
bookmarks.append((i, text))
|
||||
return bookmarks
|
||||
|
||||
|
||||
def parse_chapter_bookmark(text: str):
|
||||
"""Parse a chapter bookmark like 'TQK 1: The Original Person'.
|
||||
|
||||
Returns (chapter_number, title) or None if not a chapter bookmark.
|
||||
"""
|
||||
m = re.match(r"TQK\s+(\d+):\s+(.+)", text.strip())
|
||||
if m:
|
||||
num = int(m.group(1))
|
||||
title = m.group(2).strip()
|
||||
return num, title
|
||||
return None
|
||||
|
||||
|
||||
def parse_intro_bookmark(text: str):
|
||||
"""Check if this is a front-pages or introduction bookmark.
|
||||
|
||||
Returns the slug or None.
|
||||
"""
|
||||
stripped = text.strip()
|
||||
if stripped == "TQK Front flap":
|
||||
return "front-pages"
|
||||
if stripped == "TQK Introduction":
|
||||
return "introduction"
|
||||
return None
|
||||
|
||||
|
||||
def parse_backmatter_bookmark(text: str):
|
||||
"""Check if this is a back-matter bookmark.
|
||||
|
||||
Returns the slug or None.
|
||||
"""
|
||||
stripped = text.strip()
|
||||
if stripped == "TQK Back flap":
|
||||
return "back-flap"
|
||||
return None
|
||||
|
||||
|
||||
INTRO_SLUG_ORDER = {
|
||||
"front-pages": 1,
|
||||
"introduction": 2,
|
||||
}
|
||||
|
||||
BACKMATTER_SLUG_ORDER = {
|
||||
"back-flap": 99,
|
||||
}
|
||||
|
||||
INTRO_SLUG_TITLE = {
|
||||
"front-pages": "Front Pages",
|
||||
"introduction": "Introduction",
|
||||
"back-flap": "Back Flap",
|
||||
}
|
||||
|
||||
|
||||
def extract_paragraphs(
|
||||
all_paragraphs: list,
|
||||
start_idx: int,
|
||||
end_idx: int,
|
||||
) -> list:
|
||||
"""Extract paragraphs for a section (chapter, intro segment).
|
||||
|
||||
Handles all CSS classes within the TQK content.
|
||||
Handles p53 Sanskrit verses as centered italic divs.
|
||||
Handles structured verse headers (TEXT N, TRANSLATION, PURPORT).
|
||||
"""
|
||||
result = []
|
||||
|
||||
for i in range(start_idx, end_idx):
|
||||
p = all_paragraphs[i]
|
||||
cls = get_css_class(p)
|
||||
|
||||
# Skip empty paragraphs
|
||||
if is_empty(p):
|
||||
continue
|
||||
|
||||
# Skip noise/break classes
|
||||
if cls in SKIP_CLASSES:
|
||||
continue
|
||||
|
||||
# Skip bookmark tags (p13)
|
||||
if cls == BOOKMARK_CLASS:
|
||||
continue
|
||||
|
||||
# Skip book title header (p12)
|
||||
if cls == BOOK_HEADER_CLASS:
|
||||
continue
|
||||
|
||||
# Skip glossary heading (p19)
|
||||
if cls == GLOSSARY_CLASS:
|
||||
continue
|
||||
|
||||
# Check for dash lines — skip them
|
||||
text = decode_text(p)
|
||||
if is_dash_line(text):
|
||||
continue
|
||||
|
||||
# Sanskrit verse text (p53) — italic, multi-line with <br>
|
||||
if cls == SANSKRIT_VERSE_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
# Strip outer <i> tags, then re-wrap in centered italic div
|
||||
inner = re.sub(r"</?i>", "", html)
|
||||
result.append(f'<div style="text-align:center"><i>{inner}</i></div>')
|
||||
continue
|
||||
|
||||
# Structured headers: TEXT N, TRANSLATION, PURPORT (p55)
|
||||
if cls == STRUCTURED_HEADER_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
# These are already bold in the source; ensure bold wrapping
|
||||
if not html.strip().startswith("<b>"):
|
||||
html = f"<b>{html}</b>"
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Translation text (p211) — bold verse translation
|
||||
if cls == TRANSLATION_TEXT_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
if not html.strip().startswith("<b>"):
|
||||
html = f"<b>{html}</b>"
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Verse reference (p212 or p216) — e.g. —Śrīmad-Bhāgavatam 1.8.18
|
||||
if cls in (VERSE_REFERENCE_CLASS_212, VERSE_REFERENCE_CLASS_216):
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Body paragraphs (p6) — the main content class
|
||||
if cls == BODY_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
# Detect Sanskrit verses disguised as body paragraphs:
|
||||
# they start with <i> and contain <br> (multi-line italic = verse)
|
||||
stripped_html = html.strip()
|
||||
if stripped_html.startswith("<i>") and "<br" in stripped_html:
|
||||
inner = re.sub(r"</?i>", "", stripped_html)
|
||||
result.append(
|
||||
f'<div style="text-align:center"><i>{inner}</i></div>'
|
||||
)
|
||||
else:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Verse translation text (p44) — after Sanskrit verses mid-purport
|
||||
if cls == VERSE_TRANSLATION_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Chapter heading (p27) — bold chapter title
|
||||
if cls == CHAPTER_HEADING_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
if not html.strip().startswith("<b>"):
|
||||
html = f"<b>{html}</b>"
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Section heading (p31) — bold section title
|
||||
if cls == SECTION_HEADING_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
if not html.strip().startswith("<b>"):
|
||||
html = f"<b>{html}</b>"
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Short text lines (p118)
|
||||
if cls == SHORT_TEXT_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Italic/centered text (p25) — e.g. catalog note, publisher address
|
||||
if cls == CENTERED_ITALIC_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Block quotes (p23) — indented quotes in introduction
|
||||
if cls == BLOCK_QUOTE_CLASS_23:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Publication info (p214, p215)
|
||||
if cls in (PUBLICATION_INFO_CLASS_214, PUBLICATION_INFO_CLASS_215):
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Testimonial quotes (p18) — back flap
|
||||
if cls == TESTIMONIAL_QUOTE_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Testimonial attribution (p147) — back flap
|
||||
if cls == TESTIMONIAL_ATTRIBUTION_CLASS:
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Jacket/design credits (p3, p21)
|
||||
if cls in (JACKET_CREDITS_CLASS_3, JACKET_CREDITS_CLASS_21):
|
||||
html = decode_html_content(p)
|
||||
if html:
|
||||
result.append(html)
|
||||
continue
|
||||
|
||||
# Any other class — try to extract content (be permissive)
|
||||
html = decode_html_content(p)
|
||||
if html and not is_dash_line(text):
|
||||
result.append(html)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main extraction logic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_tqk(soup: BeautifulSoup) -> dict:
|
||||
"""Extract the entire Teachings of Queen Kuntī from the parsed HTML.
|
||||
|
||||
Returns a dict with:
|
||||
"intro_segments": list of intro segment dicts
|
||||
"chapters": list of chapter dicts
|
||||
"chapter_listing": list of {number, title} for intro.json
|
||||
"""
|
||||
all_paragraphs = list(soup.find_all("p"))
|
||||
|
||||
# Step 1: Find TQK boundaries
|
||||
tqk_start, tqk_end = find_tqk_boundaries(all_paragraphs)
|
||||
print(f" TQK content: paragraphs {tqk_start} to {tqk_end}")
|
||||
print(f" Total paragraphs in range: {tqk_end - tqk_start}")
|
||||
|
||||
# Step 2: Find all bookmarks
|
||||
bookmarks = find_all_bookmarks(all_paragraphs, tqk_start, tqk_end)
|
||||
print(f" Found {len(bookmarks)} TQK bookmarks")
|
||||
for pos, text in bookmarks:
|
||||
print(f" Para ~{pos}: {text}")
|
||||
|
||||
# Step 3: Categorize bookmarks
|
||||
intro_bookmarks = {} # slug -> paragraph index
|
||||
backmatter_bookmarks = {} # slug -> paragraph index
|
||||
chapter_bookmarks = [] # (para_idx, chapter_num, title)
|
||||
|
||||
for pos, text in bookmarks:
|
||||
# Check chapter
|
||||
ch = parse_chapter_bookmark(text)
|
||||
if ch:
|
||||
chapter_bookmarks.append((pos, ch[0], ch[1]))
|
||||
continue
|
||||
|
||||
# Check intro (front-pages, introduction)
|
||||
intro_slug = parse_intro_bookmark(text)
|
||||
if intro_slug:
|
||||
intro_bookmarks[intro_slug] = pos
|
||||
continue
|
||||
|
||||
# Check back matter (back-flap)
|
||||
bm_slug = parse_backmatter_bookmark(text)
|
||||
if bm_slug:
|
||||
backmatter_bookmarks[bm_slug] = pos
|
||||
continue
|
||||
|
||||
print(f" WARNING: Unrecognized bookmark: {text}")
|
||||
|
||||
print(f" Intro bookmarks: {list(intro_bookmarks.keys())}")
|
||||
print(f" Chapter bookmarks: {len(chapter_bookmarks)}")
|
||||
print(f" Back matter bookmarks: {list(backmatter_bookmarks.keys())}")
|
||||
|
||||
# Build an ordered list of all section start positions for boundary computation
|
||||
all_section_starts = sorted(
|
||||
list(intro_bookmarks.values())
|
||||
+ [pos for pos, _, _ in chapter_bookmarks]
|
||||
+ list(backmatter_bookmarks.values())
|
||||
)
|
||||
|
||||
def next_section_after(pos):
|
||||
"""Return the next section start after `pos`, or tqk_end."""
|
||||
for s in all_section_starts:
|
||||
if s > pos:
|
||||
return s
|
||||
return tqk_end
|
||||
|
||||
# Step 4: Extract intro segments (front-pages, introduction)
|
||||
intro_segments = []
|
||||
for slug in ["front-pages", "introduction"]:
|
||||
if slug in intro_bookmarks:
|
||||
bm_pos = intro_bookmarks[slug]
|
||||
content_start = bm_pos + 1
|
||||
content_end = next_section_after(bm_pos)
|
||||
paras = extract_paragraphs(all_paragraphs, content_start, content_end)
|
||||
seg = {
|
||||
"slug": slug,
|
||||
"title": INTRO_SLUG_TITLE[slug],
|
||||
"type": "intro-segment",
|
||||
"book": "tqk",
|
||||
"order": INTRO_SLUG_ORDER[slug],
|
||||
"paragraphs": paras,
|
||||
}
|
||||
intro_segments.append(seg)
|
||||
|
||||
# Step 5: Extract chapters 1–26
|
||||
chapters = []
|
||||
for idx, (ch_pos, ch_num, ch_title) in enumerate(chapter_bookmarks):
|
||||
content_start = ch_pos + 1
|
||||
content_end = next_section_after(ch_pos)
|
||||
|
||||
paragraphs = extract_paragraphs(all_paragraphs, content_start, content_end)
|
||||
|
||||
ch = {
|
||||
"book": "tqk",
|
||||
"chapter": ch_num,
|
||||
"title": ch_title,
|
||||
"type": "chapter",
|
||||
"paragraphs": paragraphs,
|
||||
}
|
||||
chapters.append(ch)
|
||||
|
||||
# Step 6: Extract back matter segments (back-flap)
|
||||
backmatter_segments = []
|
||||
for slug in ["back-flap"]:
|
||||
if slug in backmatter_bookmarks:
|
||||
bm_pos = backmatter_bookmarks[slug]
|
||||
content_start = bm_pos + 1
|
||||
content_end = next_section_after(bm_pos)
|
||||
paras = extract_paragraphs(all_paragraphs, content_start, content_end)
|
||||
seg = {
|
||||
"slug": slug,
|
||||
"title": INTRO_SLUG_TITLE[slug],
|
||||
"type": "intro-segment",
|
||||
"book": "tqk",
|
||||
"order": BACKMATTER_SLUG_ORDER[slug],
|
||||
"paragraphs": paras,
|
||||
}
|
||||
backmatter_segments.append(seg)
|
||||
|
||||
# Combine all intro segments (front + back matter)
|
||||
all_intro_segments = intro_segments + backmatter_segments
|
||||
|
||||
# Step 7: Build chapter listing for intro.json
|
||||
chapter_listing = [
|
||||
{"number": ch["chapter"], "title": ch["title"]} for ch in chapters
|
||||
]
|
||||
|
||||
return {
|
||||
"intro_segments": all_intro_segments,
|
||||
"chapters": chapters,
|
||||
"chapter_listing": chapter_listing,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def write_json(data, filepath):
|
||||
"""Write data as formatted JSON to filepath, creating directories as needed."""
|
||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def main():
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
html_path = os.path.join(base_dir, "public", "html", "other.html")
|
||||
output_base = os.path.join(base_dir, "lib", "jsons", "books", "tqk")
|
||||
|
||||
if not os.path.exists(html_path):
|
||||
print(f"ERROR: {html_path} not found!")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Reading {html_path}...")
|
||||
with open(html_path, "r", encoding="utf-8") as f:
|
||||
soup = BeautifulSoup(f, "html.parser")
|
||||
|
||||
print("Extracting Teachings of Queen Kuntī (1978)...\n")
|
||||
result = extract_tqk(soup)
|
||||
|
||||
intro_segments = result["intro_segments"]
|
||||
chapters = result["chapters"]
|
||||
chapter_listing = result["chapter_listing"]
|
||||
|
||||
# --- Write intro.json ---
|
||||
intro_meta = {
|
||||
"book": "tqk",
|
||||
"title": "Teachings of Queen Kuntī",
|
||||
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
|
||||
"year": 1978,
|
||||
"description": "Queen Kuntī's prayers to Lord Kṛṣṇa from Śrīmad-Bhāgavatam with commentary by Śrīla Prabhupāda.",
|
||||
"totalChapters": 26,
|
||||
"chapters": chapter_listing,
|
||||
}
|
||||
intro_path = os.path.join(output_base, "intro.json")
|
||||
write_json(intro_meta, intro_path)
|
||||
print(f"\n Written: {intro_path}")
|
||||
|
||||
# --- Write intro segments ---
|
||||
print(f"\n--- Intro / Back-Matter Segments ---")
|
||||
for seg in intro_segments:
|
||||
filepath = os.path.join(output_base, "intro", f"{seg['slug']}.json")
|
||||
write_json(seg, filepath)
|
||||
print(
|
||||
f" {seg['order']:>2}. {seg['title']}: {len(seg['paragraphs'])} paragraphs → {filepath}"
|
||||
)
|
||||
|
||||
# --- Write chapters ---
|
||||
print(f"\n--- Chapters ---")
|
||||
for ch in chapters:
|
||||
ch_num_str = str(ch["chapter"]).zfill(2)
|
||||
filepath = os.path.join(output_base, "chapters", f"{ch_num_str}.json")
|
||||
write_json(ch, filepath)
|
||||
print(
|
||||
f" Ch {ch['chapter']:>2}: {len(ch['paragraphs']):>3} paragraphs — "
|
||||
f"{ch['title']}"
|
||||
)
|
||||
|
||||
# --- Summary ---
|
||||
total_intro_paras = sum(len(s["paragraphs"]) for s in intro_segments)
|
||||
total_chapter_paras = sum(len(ch["paragraphs"]) for ch in chapters)
|
||||
total_paras = total_intro_paras + total_chapter_paras
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" EXTRACTION SUMMARY")
|
||||
print(f"{'=' * 60}")
|
||||
print(f" Intro/back-matter segments: {len(intro_segments)}")
|
||||
for seg in intro_segments:
|
||||
print(
|
||||
f" {seg['order']:>2}. {seg['title']}: {len(seg['paragraphs'])} paragraphs"
|
||||
)
|
||||
print(f" Chapters: {len(chapters)}")
|
||||
print(f" Total paragraphs: {total_paras}")
|
||||
print(f" Intro/back: {total_intro_paras}")
|
||||
print(f" Chapters: {total_chapter_paras}")
|
||||
if chapters:
|
||||
print(f" Avg paras/chapter: {total_chapter_paras / len(chapters):.1f}")
|
||||
print(f" Output directory: {output_base}/")
|
||||
|
||||
# --- Samples ---
|
||||
print(f"\n--- Sample: Chapter 1 ---")
|
||||
if chapters:
|
||||
ch1 = chapters[0]
|
||||
for i, p in enumerate(ch1["paragraphs"][:5]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:120]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
if len(chapters) >= 13:
|
||||
print(f"\n--- Sample: Chapter 13 ---")
|
||||
ch13 = [c for c in chapters if c["chapter"] == 13]
|
||||
if ch13:
|
||||
for i, p in enumerate(ch13[0]["paragraphs"][:3]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:120]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
if len(chapters) >= 26:
|
||||
print(f"\n--- Sample: Chapter 26 (last) ---")
|
||||
last = chapters[-1]
|
||||
for i, p in enumerate(last["paragraphs"][:3]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:120]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
# --- Quality Checks ---
|
||||
print(f"\n--- Quality Checks ---")
|
||||
|
||||
# Check all 26 chapters present
|
||||
expected_chapters = set(range(1, 27))
|
||||
actual_chapters = set(ch["chapter"] for ch in chapters)
|
||||
missing = expected_chapters - actual_chapters
|
||||
extra = actual_chapters - expected_chapters
|
||||
if missing:
|
||||
print(f" ⚠ Missing chapters: {sorted(missing)}")
|
||||
if extra:
|
||||
print(f" ⚠ Extra chapters: {sorted(extra)}")
|
||||
if not missing and not extra:
|
||||
print(f" ✅ All 26 chapters present")
|
||||
|
||||
# Check intro segments
|
||||
expected_intros = {
|
||||
"front-pages",
|
||||
"introduction",
|
||||
"back-flap",
|
||||
}
|
||||
actual_intros = {s["slug"] for s in intro_segments}
|
||||
missing_intros = expected_intros - actual_intros
|
||||
extra_intros = actual_intros - expected_intros
|
||||
if missing_intros:
|
||||
print(f" ⚠ Missing intro segments: {missing_intros}")
|
||||
if extra_intros:
|
||||
print(f" ⚠ Extra intro segments: {extra_intros}")
|
||||
if not missing_intros and not extra_intros:
|
||||
print(f" ✅ All 3 intro/back-matter segments present")
|
||||
|
||||
# Check empty chapters
|
||||
empty_chapters = [ch["chapter"] for ch in chapters if len(ch["paragraphs"]) == 0]
|
||||
if empty_chapters:
|
||||
print(f" ⚠ Empty chapters (0 paragraphs): {empty_chapters}")
|
||||
else:
|
||||
print(f" ✅ No empty chapters")
|
||||
|
||||
# Check empty intro segments
|
||||
empty_intros = [s["slug"] for s in intro_segments if len(s["paragraphs"]) == 0]
|
||||
if empty_intros:
|
||||
print(f" ⚠ Empty intro segments (0 paragraphs): {empty_intros}")
|
||||
else:
|
||||
print(f" ✅ No empty intro segments")
|
||||
|
||||
# Check ScaGoudy decoding — look for common encoded characters
|
||||
encoded_chars = set("äåöëòçüéàùè")
|
||||
suspect_count = 0
|
||||
all_sections = chapters + intro_segments
|
||||
for section in all_sections:
|
||||
for p in section["paragraphs"]:
|
||||
plain = re.sub(r"<[^>]+>", "", p)
|
||||
for c in encoded_chars:
|
||||
if c in plain:
|
||||
suspect_count += 1
|
||||
break
|
||||
if suspect_count == 0:
|
||||
print(f" ✅ No suspected undecoded ScaGoudy characters found")
|
||||
else:
|
||||
print(
|
||||
f" ℹ {suspect_count} paragraphs contain characters that may be undecoded ScaGoudy"
|
||||
)
|
||||
|
||||
# Check verse blocks (centered italic divs)
|
||||
verse_block_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if '<div style="text-align:center"><i>' in p:
|
||||
verse_block_count += 1
|
||||
print(f" ℹ {verse_block_count} Sanskrit verse blocks in chapters")
|
||||
|
||||
# Count structured verse headers (TEXT N, TRANSLATION, PURPORT)
|
||||
text_n_count = 0
|
||||
translation_header_count = 0
|
||||
purport_header_count = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
plain = re.sub(r"<[^>]+>", "", p).strip()
|
||||
if re.match(r"TEXT\s+\d+", plain):
|
||||
text_n_count += 1
|
||||
elif plain == "TRANSLATION":
|
||||
translation_header_count += 1
|
||||
elif plain == "PURPORT":
|
||||
purport_header_count += 1
|
||||
print(f" ℹ TEXT N headers: {text_n_count}")
|
||||
print(f" ℹ TRANSLATION headers: {translation_header_count}")
|
||||
print(f" ℹ PURPORT headers: {purport_header_count}")
|
||||
|
||||
# ScaGoudy decoding spot check
|
||||
print(f"\n--- ScaGoudy Decoding Spot Check ---")
|
||||
if chapters:
|
||||
ch1_text = " ".join(chapters[0]["paragraphs"][:20])
|
||||
if "Prabhupāda" in ch1_text:
|
||||
print(f" ✅ 'Prabhupāda' correctly decoded")
|
||||
elif "Prabhupäda" in ch1_text:
|
||||
print(f" ⚠ ScaGoudy decoding incomplete for 'Prabhupäda'")
|
||||
else:
|
||||
print(f" ℹ 'Prabhupāda' not found in Chapter 1 first 20 paragraphs")
|
||||
|
||||
if "Kṛṣṇa" in ch1_text:
|
||||
print(f" ✅ 'Kṛṣṇa' correctly decoded in Chapter 1")
|
||||
elif "Kåñëa" in ch1_text:
|
||||
print(f" ⚠ 'Kåñëa' still encoded — ScaGoudy decoding may have failed!")
|
||||
else:
|
||||
print(
|
||||
f" ℹ 'Kṛṣṇa' not found in Chapter 1 first 20 paragraphs (may be normal)"
|
||||
)
|
||||
|
||||
# Check for Kuntī
|
||||
if "Kuntī" in ch1_text or "Kuntīdevī" in ch1_text:
|
||||
print(f" ✅ 'Kuntī' correctly decoded")
|
||||
elif "Kunté" in ch1_text or "Kuntédevé" in ch1_text:
|
||||
print(f" ⚠ 'Kunté' still encoded")
|
||||
|
||||
# Check for Bhāgavatam
|
||||
if "Bhāgavatam" in ch1_text:
|
||||
print(f" ✅ 'Bhāgavatam' correctly decoded")
|
||||
elif "Bhägavatam" in ch1_text:
|
||||
print(f" ⚠ 'Bhägavatam' still encoded")
|
||||
|
||||
# Sample verse blocks
|
||||
print(f"\n--- Sample Sanskrit Verse Blocks ---")
|
||||
shown = 0
|
||||
for ch in chapters:
|
||||
for p in ch["paragraphs"]:
|
||||
if '<div style="text-align:center"><i>' in p:
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:120]
|
||||
print(f" Ch {ch['chapter']}: {preview}...")
|
||||
shown += 1
|
||||
if shown >= 3:
|
||||
break
|
||||
if shown >= 3:
|
||||
break
|
||||
|
||||
# Sample structured verse sections (TRANSLATION)
|
||||
print(f"\n--- Sample Structured Verse Sections ---")
|
||||
shown = 0
|
||||
for ch in chapters[:3]:
|
||||
for i, p in enumerate(ch["paragraphs"]):
|
||||
plain = re.sub(r"<[^>]+>", "", p).strip()
|
||||
if plain == "TRANSLATION" and shown < 3:
|
||||
# Show TRANSLATION header and next few paragraphs
|
||||
print(f" Ch {ch['chapter']}:")
|
||||
start_show = max(i - 1, 0)
|
||||
for j in range(start_show, min(i + 4, len(ch["paragraphs"]))):
|
||||
pj_preview = re.sub(r"<[^>]+>", "", ch["paragraphs"][j])[:100]
|
||||
print(f" [{j}] {pj_preview}...")
|
||||
shown += 1
|
||||
print()
|
||||
if shown >= 3:
|
||||
break
|
||||
|
||||
# Check intro segments content
|
||||
print(f"--- Intro Segment Samples ---")
|
||||
for seg in intro_segments:
|
||||
print(f"\n {seg['title']} ({seg['slug']}, order {seg['order']}):")
|
||||
for i, p in enumerate(seg["paragraphs"][:2]):
|
||||
preview = re.sub(r"<[^>]+>", "", p)[:120]
|
||||
print(f" [{i}] {preview}...")
|
||||
|
||||
print(f"\nDone! ✨")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user