Files
spsays-training/scripts/lob_extract.py
T

705 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()