feat: sva extracted and seeded

This commit is contained in:
gosangam
2026-04-30 19:45:17 +05:30
parent 1f2ac2198a
commit ae6a752787
47 changed files with 17163 additions and 205 deletions
+815
View File
@@ -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()
+845
View File
@@ -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()
+904
View File
@@ -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()
+704
View File
@@ -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()
+806
View File
@@ -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()
+808
View File
@@ -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()
+233
View File
@@ -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);
});
+202
View File
@@ -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);
});
+202
View File
@@ -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);
});
+203
View File
@@ -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);
});
+233
View File
@@ -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);
});
+233
View File
@@ -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);
});
+202
View File
@@ -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);
});
+202
View File
@@ -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);
});
+202
View File
@@ -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
+858
View File
@@ -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()
+864
View File
@@ -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()