#!/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']*class="[^"]*"[^>]*>(.*?)', 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 elements VERSE_TRANSLATION_CLASS = "p200" # Text number heading (bold, e.g. 1) 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:

containing "Light of the Bhägawata" End: next

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 = "
".join(verse_accumulator) result.append(f'

{joined}
') 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 tags before re-wrapping stripped = re.sub(r"", "", 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 artifacts html = re.sub(r"\s*", "", html).strip() # Normalize nested/split bold tags: strip all /<\/b> and # re-wrap the whole text in a single …. # This fixes source artifacts like "Attracted by...upon" inner = re.sub(r"", "", html).strip() html = f"{inner}" 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(""): 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 '
' 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 '
' 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()