#!/usr/bin/env python3 """Extract 1962 Śrīmad-Bhāgavatam First Canto verses from others-before-1977.html. This is the original 1962-65 edition of Śrīmad-Bhāgavatam First Canto published in India before Śrīla Prabhupāda went to the USA. It spans 3 volumes: - Volume 1 (1962): Chapters 1–7 (first half) - Volume 2 (1964): Chapter 7 (second half) through Chapter 12 - Volume 3 (1965): Chapters 13–19 Key differences from the standard SB extraction: - No Devanagari or ScaGoudy encoding — transliteration is plain English (non-IAST) - Different CSS class mappings - Verse markers use "TEXT No. N" format (p18 class) - Reference markers (p17) are sparse — only some verses have them - Chapter boundaries use spelled-out ordinals: "FIRST CHAPTER", "SECOND CHAPTER", etc. - Multi-verse entries use "TEXTS No.'s 3 AND 4" format Output goes to app/lib/books/sb1962/ """ import json import os import re import sys from bs4 import BeautifulSoup, Tag # --------------------------------------------------------------------------- # Chapter name → number mapping # --------------------------------------------------------------------------- ORDINAL_TO_NUM = { "first": 1, "second": 2, "third": 3, "fourth": 4, "fifth": 5, "sixth": 6, "seventh": 7, "eighth": 8, "nineth": 9, # sic — typo in the original "ninth": 9, "tenth": 10, "eleventh": 11, "twelfth": 12, "thirteenth": 13, "fourteenth": 14, "fifteenth": 15, "sixteenth": 16, "seventeenth": 17, "eighteenth": 18, "nineteenth": 19, } # Chapter titles (from the "Thus end" markers and chapter headers) CHAPTER_TITLES = { 1: "Questions by the Sages", 2: "Divinity and Devotional Service", 3: "Description of Incarnations of Godhead", 4: "Appearance of Sri Narada", 5: "Sri Narada's Instructions on Srimad Bhagwatam for Vyasdeva", 6: "Conversation Between Narada and Vyasa", 7: "The Son of Drona Punished", 8: "Prayers of Queen Kunti and Parikshit Saved", 9: "Passing Away of Bhismadeva in the Presence of Lord Krishna", 10: "Departure of Lord Krishna for Dwarka", 11: "Lord Krishna's Entrance at Dwarka", 12: "Birth of Maharaj Parikshit", 13: "Dhritarastra Quits Home", 14: "Disappearance of Lord Krishna", 15: "The Pandavas Retire Timely", 16: "How Parikshit Received The Age of Kali", 17: "Punishment and Reward of Kali", 18: "Maharaj Parikshit Cursed by a Brahmin Boy", 19: "Appearance of Sri Sukdeva Goswami", } # --------------------------------------------------------------------------- # HTML helpers # --------------------------------------------------------------------------- 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 decode_html_content(tag: Tag) -> str: """Extract inner HTML preserving / formatting.""" inner = "".join(str(child) for child in tag.children) # Remove span tags with CSS classes (RTF conversion artifacts) inner = re.sub(r']*class="[^"]*"[^>]*>(.*?)', r"\1", inner) inner = re.sub(r"\s+", " ", inner).strip() return inner def decode_text(tag: Tag) -> str: """Extract plain text from a tag.""" return tag.get_text().strip() # --------------------------------------------------------------------------- # Synonym parser # --------------------------------------------------------------------------- def parse_synonyms(text: str) -> list: """Parse synonym text from the 1962 edition. Format is similar to later editions but uses plain English: "Om—Oh my Lord, namo—offering my obeisances, ..." Words may be wrapped in tags. """ # Strip HTML tags for parsing plain = re.sub(r"<[^>]+>", "", text).strip() if not plain: return [] result = [] # Split on comma that's followed by an italic word (the next synonym) # But also handle semicolons # The pattern is: word—meaning, word—meaning, ... # Split on ", " followed by what looks like a new word entry # Actually simpler: split on the em-dash pattern entries = re.split(r",\s*(?=[A-Z])", plain) 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) elif "- " in entry: # Some entries use hyphen-space parts = entry.split("- ", 1) else: continue if len(parts) == 2: word = parts[0].strip().strip("., ;:") meaning = parts[1].strip().rstrip(".,") if word and meaning: result.append({"word": word, "meanings": [meaning]}) return result # --------------------------------------------------------------------------- # CSS class sets for the 1962 SB # --------------------------------------------------------------------------- # Verse reference bookmark (sparse — not all verses have these) VERSE_REF_CLASS = "p17" # TEXT No. N header TEXT_HEADER_CLASS = "p18" # "TEXT" label TEXT_LABEL_CLASS = "p2" # Transliteration pāda lines (italic, centered) TRANSLITERATION_CLASSES = frozenset(["p19", "p87"]) # Section header class: "ENGLISH SYNONYMS", "TRANSLATION", "PURPORT" SECTION_HEADER_CLASSES = frozenset(["p20", "p8"]) # Synonyms text class SYNONYM_CLASSES = frozenset(["p88"]) # Translation text class TRANSLATION_CLASSES = frozenset(["p89"]) # Purport paragraph classes PURPORT_CLASSES = frozenset(["p74", "p6", "p48"]) # Chapter heading class CHAPTER_HEADING_CLASS = "p3" # Chapter bookmark class CHAPTER_BOOKMARK_CLASS = "p11" # Volume/section boundary classes — skip SKIP_CLASSES = frozenset( [ "p35", # volume title "p8", # various front matter and banners (also used for some headers) "p73", # "First Published in 1962" "p105", # chapter subtitle in volume 2 "p106", # chapter subtitle in volume 3 "p71", # review quotes "p72", # review quotes ] ) # Line range in the HTML for the SB 1962 content # From the first chapter marker to "END OF THE FIRST CANTO" SB1962_START_LINE = 4726 SB1962_END_LINE = 15162 # --------------------------------------------------------------------------- # Verse number parser # --------------------------------------------------------------------------- def parse_text_header(text: str): """Parse a TEXT header like 'TEXT No. 1' or 'TEXTS No.'s 3 AND 4'. Returns (verse_num, verse_end) or None. """ text = text.strip() # Multi-verse: "TEXTS No.'s 3 AND 4" or "TEXTS Nos. 26 AND 27" m = re.match( r"TEXTS?\s+No\.?'?s?\s*(\d+)\s+AND\s+(\d+)", text, re.IGNORECASE, ) if m: return (int(m.group(1)), int(m.group(2))) # Single verse: "TEXT No. 1" or "TEXT No. 1." m = re.match(r"TEXT\s+No\.?\s*(\d+)", text, re.IGNORECASE) if m: return (int(m.group(1)), None) return None def parse_chapter_heading(text: str): """Parse a chapter heading like 'FIRST CHAPTER' or 'SEVENTH CHAPTER'. Returns chapter number or None. """ text = text.strip().upper() # Remove bold/italic artifacts text = re.sub(r"<[^>]+>", "", text).strip() m = re.match(r"(\w+)\s+CHAPTER", text) if m: ordinal = m.group(1).lower() return ORDINAL_TO_NUM.get(ordinal) return None def parse_ref_marker(text: str): """Parse a reference marker like 'SB. 1-1962:1.1.1' or 'SB. 1.13-1965:1.13.3-4'. Returns (chapter, verse, verse_end) or None. """ # Match patterns like: SB. 1-1962:1.1.1 or SB. 1.7-1964:1.7.9 or SB. 1.13-1965:1.13.3-4 m = re.search(r"SB\.\s*[\d.]+-\d{4}:1\.(\d+)\.(\d+)(?:-(\d+))?", text) if m: chapter = int(m.group(1)) verse = int(m.group(2)) verse_end = int(m.group(3)) if m.group(3) else None return (chapter, verse, verse_end) return None # --------------------------------------------------------------------------- # State machine extraction # --------------------------------------------------------------------------- def _finalize_verse(verse: dict): """Clean up a verse before appending to results.""" # Join transliteration lines padas = verse.pop("_padas", []) verse["transliteration"] = "\n".join(padas) # Clean up internal state verse.pop("_state", None) def extract_verses(soup: BeautifulSoup): """Extract all SB 1962 verses from the HTML. Returns (verses, chapter_count). """ all_paragraphs = soup.find_all("p") # Filter to only the paragraphs in the SB 1962 range # We use the sourceline attribute if available, otherwise fall back to # scanning for the start/end markers paragraphs = [] in_range = False for p in all_paragraphs: cls = get_css_class(p) text = decode_text(p) # Start: first chapter marker if cls == CHAPTER_BOOKMARK_CLASS and "SB. 1-1962:: First Chapter" in text: in_range = True if in_range: paragraphs.append(p) # End: "END OF THE FIRST CANTO" if in_range and "END OF THE FIRST CANTO" in text: break if not paragraphs: print("ERROR: Could not find SB 1962 content in the HTML!") return [], 0 print(f" Found {len(paragraphs)} paragraphs in SB 1962 range") verses = [] current_verse = None state = "IDLE" current_chapter = 0 chapters_seen = set() for p in paragraphs: cls = get_css_class(p) text = decode_text(p) # --- Chapter heading (p3): "FIRST CHAPTER", "SECOND CHAPTER", etc. --- if cls == CHAPTER_HEADING_CLASS: ch_num = parse_chapter_heading(text) if ch_num is not None: # Finalize previous verse if any if current_verse: _finalize_verse(current_verse) verses.append(current_verse) current_verse = None state = "IDLE" current_chapter = ch_num chapters_seen.add(ch_num) continue # Skip non-chapter headings (APPENDIX, CONTENTS, PREFACE, etc.) if any( kw in text.upper() for kw in ["APPENDIX", "CONTENTS", "PREFACE", "ERRATA"] ): # We've left the chapter content — stop processing until next chapter if current_verse: _finalize_verse(current_verse) verses.append(current_verse) current_verse = None state = "SKIP" continue # --- Skip mode (appendix, front matter between volumes) --- if state == "SKIP": # Resume when we hit a new chapter heading if cls == CHAPTER_HEADING_CLASS: ch_num = parse_chapter_heading(text) if ch_num is not None: current_chapter = ch_num chapters_seen.add(ch_num) state = "IDLE" continue # Also resume on a verse ref marker for the next volume if cls == VERSE_REF_CLASS and "SB." in text: ref = parse_ref_marker(text) if ref: state = "IDLE" # Fall through to handle the ref below else: continue else: continue # --- Chapter bookmark (p11) — might give us the chapter number --- if cls == CHAPTER_BOOKMARK_CLASS: # Could be "SB. 1-1962:: First Chapter" or "SB. 1.7-1964: ..." # We already handle chapter number from the p3 heading continue # --- Volume/section boundaries — skip --- if cls in SKIP_CLASSES: continue # --- Verse reference marker (p17) --- if cls == VERSE_REF_CLASS: ref = parse_ref_marker(text) if ref: # This gives us chapter/verse info, but the TEXT header is the # primary verse trigger. Just note the chapter if different. ch, vs, vs_end = ref if ch != current_chapter and ch > 0: current_chapter = ch chapters_seen.add(ch) continue # --- TEXT No. N header (p18) — primary verse trigger --- if cls == TEXT_HEADER_CLASS: parsed = parse_text_header(text) if parsed is None: continue verse_num, verse_end = parsed # Finalize previous verse if current_verse: _finalize_verse(current_verse) verses.append(current_verse) if verse_end: ref_str = f"SB 1.{current_chapter}.{verse_num}-{verse_end}" else: ref_str = f"SB 1.{current_chapter}.{verse_num}" current_verse = { "book_title": "Śrīmad-Bhāgavatam (1962 Edition)", "chapter": current_chapter, "verse": verse_num, "reference": ref_str, "transliteration": "", "_padas": [], "synonyms": [], "translation": "", "purport": [], "_state": "VERSE_START", } if verse_end is not None: current_verse["verse_end"] = verse_end state = "VERSE_START" continue # --- TEXT label (p2) — just confirms we're in a verse --- if cls == TEXT_LABEL_CLASS and current_verse: continue # --- Transliteration lines (p19, p87) --- if ( cls in TRANSLITERATION_CLASSES and current_verse and state in ("VERSE_START", "TRANSLITERATION") ): state = "TRANSLITERATION" current_verse["_state"] = state line = decode_text(p) if line: current_verse["_padas"].append(line) continue # --- Section headers (p20, p8): ENGLISH SYNONYMS, TRANSLATION, PURPORT --- if cls in SECTION_HEADER_CLASSES and current_verse: header_text = text.strip().upper() if "SYNONYM" in header_text: state = "SYNONYMS" current_verse["_state"] = state elif "TRANSLATION" in header_text: state = "TRANSLATION" current_verse["_state"] = state elif "PURPORT" in header_text: state = "PURPORT" current_verse["_state"] = state continue # --- Synonyms text (p88) --- if cls in SYNONYM_CLASSES and current_verse and state == "SYNONYMS": html_content = decode_html_content(p) current_verse["synonyms"] = parse_synonyms(html_content) continue # --- Translation text (p89) --- if cls in TRANSLATION_CLASSES and current_verse and state == "TRANSLATION": current_verse["translation"] = decode_html_content(p) state = "POST_TRANSLATION" current_verse["_state"] = state continue # --- Handle mislabeled headers (TRANSLATION header followed by # translation in p89 while state is PURPORT) --- if ( cls in TRANSLATION_CLASSES and current_verse and not current_verse.get("translation") ): current_verse["translation"] = decode_html_content(p) state = "POST_TRANSLATION" current_verse["_state"] = state continue # --- Purport paragraphs (p74, p6, p48) --- if state == "PURPORT" and current_verse and cls in PURPORT_CLASSES: content = decode_html_content(p) if content and "Thus end" not in text and "Thus ended" not in text: current_verse["purport"].append(content) continue # --- Chapter end marker ("Thus end the Bhaktivedanta Purports") --- if ("Thus end" in text or "Thus ended" in text) and current_verse: _finalize_verse(current_verse) verses.append(current_verse) current_verse = None state = "IDLE" continue # Don't forget the last verse if current_verse: _finalize_verse(current_verse) verses.append(current_verse) return verses, len(chapters_seen) # --------------------------------------------------------------------------- # JSON output # --------------------------------------------------------------------------- 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", "others-before-1977.html") output_dir = os.path.join(base_dir, "lib", "jsons", "books", "sb-1962") 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 SB 1962 verses...") verses, chapter_count = extract_verses(soup) print(f"\nExtracted {len(verses)} verses across {chapter_count} chapters") # Group by chapter chapters_info = {} for verse in verses: ch = verse["chapter"] if ch not in chapters_info: chapters_info[ch] = 0 chapters_info[ch] += 1 # Build intro.json chapters_list = [] for ch_num in sorted(chapters_info.keys()): chapters_list.append( { "number": ch_num, "title": CHAPTER_TITLES.get(ch_num, f"Chapter {ch_num}"), "totalVerses": chapters_info[ch_num], } ) intro = { "book": "sb1962", "title": "Śrīmad-Bhāgavatam — First Canto (1962 Edition)", "author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda", "description": "The original 1962-65 Indian edition of the First Canto, published before Śrīla Prabhupāda's journey to the West. Three volumes covering 19 chapters.", "totalChapters": len(chapters_list), "chapters": chapters_list, } 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 print("\n--- Verification ---") for ch_num in sorted(chapters_info.keys()): ch_verses = [v for v in verses if v["chapter"] == ch_num] if ch_verses: first = ch_verses[0] last = ch_verses[-1] title = CHAPTER_TITLES.get(ch_num, "") print( f" Ch {ch_num:>2}: {len(ch_verses):>3} verses " f"({first['reference']} → {last['reference']}) {title}" ) # Quick quality check if verses: print("\n--- Sample (first verse) ---") v = verses[0] print(f"Reference: {v['reference']}") print(f"Transliteration:\n{v['transliteration']}") print(f"Synonyms: {len(v['synonyms'])} entries") if v["synonyms"]: print(f" First: {v['synonyms'][0]}") trans = v["translation"][:120] if v["translation"] else "(none)" print(f"Translation: {trans}...") print(f"Purport: {len(v['purport'])} paragraphs") # Quality stats print("\n--- Quality Stats ---") empty_trans = sum(1 for v in verses if not v.get("transliteration", "").strip()) empty_translation = sum(1 for v in verses if not v.get("translation", "").strip()) empty_syn = sum(1 for v in verses if not v.get("synonyms")) has_purport = sum(1 for v in verses if v.get("purport")) print(f"Total verses: {len(verses)}") print(f"Empty transliteration: {empty_trans}") print(f"Empty translation: {empty_translation}") print(f"Empty synonyms: {empty_syn}") print(f"Verses with purport: {has_purport}") print(f"Verses without purport: {len(verses) - has_purport}") if __name__ == "__main__": main()