#!/usr/bin/env python3 """Extract Bhagavad Gita verses from 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 = { "ä": "ā", "Ä": "Ā", "å": "ṛ", "Å": "Ṛ", "ñ": "ṣ", "Ñ": "Ṣ", "ö": "ṭ", "Ö": "Ṭ", "ë": "ṇ", "Ë": "Ṇ", "ò": "ḍ", "Ò": "Ḍ", "ç": "ś", "Ç": "Ś", "ì": "ṅ", "ï": "ñ", "ü": "ū", "Ü": "Ū", "é": "ī", "É": "Ī", "à": "ṁ", "ù": "ḥ", } 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.""" # Get inner HTML as string inner = "".join(str(child) for child in tag.children) # Decode ScaGoudy characters inner = decode_scagoudy(inner) # Remove span tags with CSS classes (artifacts from RTF conversion) inner = re.sub(r']*class="[^"]*"[^>]*>(.*?)', r"\1", inner) # Clean up 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. In modern Devanagari convention, anusvāra (ं) is preferred over explicit nasal consonants (ङ्, ञ्, ण्, न्, म्) when they appear before consonants of their own class. e.g., सङ्गम्य → संगम्य, सञ्जय → संजय """ # 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 (्) and the next word starts with a consonant. In standard Devanagari verse rendering, a word ending in a halant (virāma) connects to the following consonant-initial word without a space. e.g., श्रीर् विजयो → श्रीर्विजयो But when followed by a vowel, the space is preserved: e.g., किम् अकुर्वत → किम् अकुर्वत (stays separate) """ # Devanagari consonants: क (U+0915) through ह (U+0939) return re.sub(r"्\s+([क-ह])", r"्\1", 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) # Normalize class nasals to anusvāra deva = normalize_devanagari_anusvara(deva) # Join words where previous word ends with virāma (्) 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" Some entries use em-dash (—) and some use double-hyphen. """ result = [] # Split by semicolons entries = text.split(";") for entry in entries: entry = entry.strip() if not entry: continue # Split on em-dash (—) or double-hyphen if "—" in entry: parts = entry.split("—", 1) elif "--" in entry: parts = entry.split("--", 1) else: # Sometimes just "word-meaning" with single hyphen at boundary # Skip entries without clear separator 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 parse_synonym_html(tag: Tag) -> list: """Parse synonyms from HTML tag, preserving italic markup for words.""" text = decode_text(tag) return parse_synonyms(text) def pair_padas(padas: list) -> str: """Pair pādas into verse lines (2 pādas per line). A śloka has 4 pādas displayed as 2 lines. If there's an odd number, the first pāda stands alone (usually a speaker attribution like 'arjuna uvāca'). """ if not padas: return "" lines = [] i = 0 # If odd number of pādas, first one is standalone (speaker attribution) if len(padas) % 2 == 1: lines.append(padas[0]) i = 1 # Pair remaining pādas while i < len(padas) - 1: lines.append(padas[i] + " " + padas[i + 1]) i += 2 # Handle any trailing single pāda if i < len(padas): lines.append(padas[i]) return "\n".join(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 _finalize_verse(verse: dict): """Build transliteration and Devanagari from pādas. Remove internal fields.""" padas = verse.get("_padas", []) # Transliteration: one pāda per line (original structure) verse["transliteration"] = "\n".join(padas) # Devanagari: pair pādas into 2-line verse structure if padas: verse["devanagari"] = iast_to_devanagari(pair_padas(padas)) del verse["_padas"] def extract_verses(soup: BeautifulSoup, max_chapter=1, max_verse=5): """Extract verses from parsed HTML using state machine.""" paragraphs = soup.find_all("p") verses = [] current_verse = None state = "IDLE" # IDLE, TRANSLITERATION, SYNONYMS, TRANSLATION, PURPORT current_chapter = 0 chapter_titles = {} for p in paragraphs: cls = get_css_class(p) text = p.get_text().strip() # Chapter title if cls == "p11": decoded = decode_text(p) match = re.match(r"^(\d+)\.\s+(.+)$", decoded, re.DOTALL) if match: current_chapter = int(match.group(1)) chapter_titles[current_chapter] = ( match.group(2).replace("\n", " ").strip() ) if current_chapter > max_chapter: break # Verse reference (e.g., "Bg 1.1") elif cls == "p25": # Finalize previous verse if current_verse and state != "IDLE": _finalize_verse(current_verse) verses.append(current_verse) current_verse = None state = "IDLE" match = re.search(r"Bg\s+(\d+)\.(\d+)", text) if match: ch = int(match.group(1)) vs = int(match.group(2)) if ch > max_chapter or (ch == max_chapter and vs > max_verse): break raw_ref = decode_scagoudy(text.strip()) # For multi-verse entries like "Bg 1.16, Bg 1.17, Bg 1.18, Bg 1.16-18", # use only the last part: "Bg 1.16-18" if "," in raw_ref: clean_ref = raw_ref.rsplit(",", 1)[-1].strip() else: clean_ref = raw_ref # Detect verse range (e.g., "Bg 1.16-18") verse_end = None range_match = re.search(r"Bg\s+\d+\.(\d+)-(\d+)", clean_ref) if range_match: verse_end = int(range_match.group(2)) current_verse = { "book_title": "Bhagavad-gītā As It Is", "chapter": ch, "verse": vs, "reference": clean_ref, "devanagari": "", "transliteration": "", "_padas": [], # raw pādas, paired into lines later "synonyms": [], "translation": "", "purport": [], } if verse_end is not None: current_verse["verse_end"] = verse_end state = "VERSE_START" # TEXT header (e.g., "TEXT 1") elif cls == "p26" and current_verse: pass # Just confirms we're in a verse # TEXT label elif cls == "p27" and current_verse: pass # Skip # Devanagari in Indevr font — SKIP (will derive from transliteration) elif cls == "p28" and current_verse: state = "TRANSLITERATION" # Transliteration lines (individual pādas) # p29 is the primary class; p35 is an alternate class used for some pādas elif ( cls in ("p29", "p35") and current_verse and state in ("TRANSLITERATION", "VERSE_START") ): state = "TRANSLITERATION" line = decode_text(p) current_verse["_padas"].append(line) # Synonyms elif cls == "p30" and current_verse: state = "SYNONYMS" current_verse["synonyms"] = parse_synonym_html(p) # Section headers (TRANSLATION / PURPORT) # p31 and p33 are primary; p38 is an alternate class used in some verses elif cls in ("p31", "p33", "p38") and current_verse: header_text = text.strip().upper() if "TRANSLATION" in header_text: state = "TRANSLATION" elif "PURPORT" in header_text: state = "PURPORT" # Translation text elif cls == "p32" and current_verse and state == "TRANSLATION": current_verse["translation"] = decode_html_content(p) state = "POST_TRANSLATION" # Purport paragraphs (exclude Collier markers) elif ( state == "PURPORT" and current_verse and cls in ("p6", "p12", "p13", "p4", "p3", "p36", "p19", "p20") ): content = decode_html_content(p) if content and "Collier" not in text: current_verse["purport"].append(content) # Collier end marker (can be p24 or p34) elif cls in ("p24", "p34") and current_verse: _finalize_verse(current_verse) verses.append(current_verse) current_verse = None state = "IDLE" # Don't forget the last verse if no Collier marker if current_verse: _finalize_verse(current_verse) verses.append(current_verse) return verses, chapter_titles 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}") def main(): base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) html_path = os.path.join(base_dir, "public", "html", "bg.html") output_dir = os.path.join(base_dir, "app", "lib", "books", "bg") print(f"Reading {html_path}...") with open(html_path, "r", encoding="utf-8") as f: soup = BeautifulSoup(f, "html.parser") print("Extracting verses (all 18 chapters)...") verses, chapter_titles = extract_verses(soup, max_chapter=18, max_verse=999) print(f"\nExtracted {len(verses)} verses") # Group verses by chapter to build intro.json chapters_info = {} for verse in verses: ch = verse["chapter"] if ch not in chapters_info: chapters_info[ch] = 0 chapters_info[ch] += 1 # Write intro.json intro = { "book": "bg", "title": "Bhagavad-gītā As It Is", "author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda", "totalChapters": 18, "chapters": [ {"number": ch, "title": chapter_titles.get(ch, ""), "totalVerses": count} for ch, count in sorted(chapters_info.items()) ], } write_json(intro, os.path.join(output_dir, "intro.json")) # Write verse JSON files for verse in verses: ch = str(verse["chapter"]).zfill(2) vs = str(verse["verse"]).zfill(2) filepath = os.path.join(output_dir, "chapters", ch, "verses", f"{vs}.json") write_json(verse, filepath) # Print summary for verification print("\n--- Verification ---") for ch in sorted(chapters_info.keys()): ch_verses = [v for v in verses if v["chapter"] == ch] print(f"\nChapter {ch}: {len(ch_verses)} verses") first = ch_verses[0] last = ch_verses[-1] print(f" First: {first['reference']}") print(f" Last: {last['reference']}") if __name__ == "__main__": main()