#!/usr/bin/env python3 """ Fetch and extract pericope/paragraph data from SBLGNT. This script downloads the SBLGNT OSIS XML and extracts paragraph boundaries to augment the manually curated pericopes.json. Usage: uv run python scripts/fetch_pericope_data.py [--book BOOK] Data Sources: - SBLGNT OSIS XML: https://sblgnt.com/download/sblgnt.osis.zip - KJV with paragraph markers: https://github.com/farskipper/kjv """ from __future__ import annotations import argparse import json import re import tempfile import zipfile from pathlib import Path from urllib.request import urlretrieve from xml.etree import ElementTree as ET # OSIS XML namespace OSIS_NS = {"osis": "http://www.bibletechnologies.net/2003/OSIS/namespace"} # Book name mappings (OSIS ID -> our format) BOOK_MAP = { "Matt": "matthew", "Mark": "mark", "Luke": "luke", "John": "john", "Acts": "acts", "Rom": "romans", "1Cor": "1corinthians", "2Cor": "2corinthians", "Gal": "galatians", "Eph": "ephesians", "Phil": "philippians", "Col": "colossians", "1Thess": "1thessalonians", "2Thess": "2thessalonians", "1Tim": "1timothy", "2Tim": "2timothy", "Titus": "titus", "Phlm": "philemon", "Heb": "hebrews", "Jas": "james", "1Pet": "1peter", "2Pet": "2peter", "1John": "1john", "2John": "2john", "3John": "3john", "Jude": "jude", "Rev": "revelation", } def download_sblgnt_osis(cache_dir: Path) -> Path: """Download SBLGNT OSIS XML if not cached.""" zip_path = cache_dir / "sblgnt.osis.zip" xml_path = cache_dir / "sblgnt.osis.xml" if xml_path.exists(): print(f"Using cached: {xml_path}") return xml_path print("Downloading SBLGNT OSIS XML...") url = "https://sblgnt.com/download/sblgnt.osis.zip" cache_dir.mkdir(parents=True, exist_ok=True) urlretrieve(url, zip_path) print("Extracting...") with zipfile.ZipFile(zip_path, "r") as zf: # Find the XML file in the zip xml_files = [n for n in zf.namelist() if n.endswith(".xml")] if xml_files: zf.extract(xml_files[0], cache_dir) extracted = cache_dir / xml_files[0] extracted.rename(xml_path) return xml_path def extract_paragraphs_from_osis(xml_path: Path, book_filter: str | None = None) -> dict: """ Extract paragraph boundaries from SBLGNT OSIS XML. The SBLGNT uses

elements to mark paragraph breaks. Verse references are in elements with osisID attributes. Returns: Dictionary of book -> list of paragraph boundaries """ print(f"Parsing {xml_path}...") tree = ET.parse(xml_path) root = tree.getroot() paragraphs = {} # Find all div elements (books) for div in root.findall(".//osis:div[@type='book']", OSIS_NS): book_id = div.get("osisID", "") book_name = BOOK_MAP.get(book_id, book_id.lower()) if book_filter and book_name != book_filter: continue print(f"Processing {book_name}...") book_paragraphs = [] current_para_verses = [] current_para_start = None # Iterate through all elements in order for elem in div.iter(): tag = elem.tag.replace(f"{{{OSIS_NS['osis']}}}", "") if tag == "p": # New paragraph - save previous if exists if current_para_verses: book_paragraphs.append({ "start": current_para_start, "end": current_para_verses[-1], "verses": current_para_verses.copy(), }) current_para_verses = [] current_para_start = None elif tag == "verse": osis_id = elem.get("osisID", "") if osis_id and "." in osis_id: # Parse "John.1.1" format parts = osis_id.split(".") if len(parts) >= 3: chapter = int(parts[1]) verse = int(parts[2]) verse_ref = f"{chapter}:{verse}" if current_para_start is None: current_para_start = verse_ref current_para_verses.append(verse_ref) # Don't forget the last paragraph if current_para_verses: book_paragraphs.append({ "start": current_para_start, "end": current_para_verses[-1], "verses": current_para_verses, }) if book_paragraphs: paragraphs[book_name] = book_paragraphs return paragraphs def merge_with_pericopes( paragraphs: dict, pericopes_path: Path, ) -> dict: """ Merge extracted paragraph data with existing pericopes.json. Adds paragraph boundaries as a reference while preserving manually curated pericope divisions. """ if pericopes_path.exists(): with pericopes_path.open("r", encoding="utf-8") as f: pericopes = json.load(f) else: pericopes = { "version": "1.0", "source": "SBLGNT paragraph extraction", "books": {}, } # Add paragraph data to each book for book_name, book_paras in paragraphs.items(): if book_name not in pericopes["books"]: pericopes["books"][book_name] = { "name": book_name.title(), "pericopes": [], } # Store raw paragraph data separately pericopes["books"][book_name]["sblgnt_paragraphs"] = book_paras return pericopes def generate_pericopes_from_paragraphs(paragraphs: list[dict], book: str) -> list[dict]: """ Generate pericope entries from paragraph data. Combines small paragraphs into larger units (target: 5-15 verses). """ pericopes = [] current_verses = [] pericope_num = 1 for para in paragraphs: current_verses.extend(para["verses"]) # If we have enough verses, create a pericope if len(current_verses) >= 8 or para == paragraphs[-1]: if current_verses: start = current_verses[0] end = current_verses[-1] start_ch, start_v = map(int, start.split(":")) end_ch, end_v = map(int, end.split(":")) pericopes.append({ "id": f"{book}-{start_ch}-{start_v}-auto", "title": None, "title_ja": None, "start_chapter": start_ch, "start_verse": start_v, "end_chapter": end_ch, "end_verse": end_v, "verses": current_verses.copy(), "theme": None, "auto_generated": True, }) current_verses = [] pericope_num += 1 return pericopes def generate_paragraphs_from_sources(book: str, sources_dir: Path) -> list[dict]: """Fallback paragraph generation from local source texts. The SBLGNT OSIS download is the textual apparatus (commentary annotations), not the running text — it contains no verse elements, so paragraph extraction yields nothing. And OT books are not in SBLGNT at all. This fallback windows the verses of each chapter (from the local greek/hebrew source JSON) into fixed-size pseudo-paragraphs so that chunk/pericope translation is available for every book. """ for lang in ("greek", "hebrew"): path = sources_dir / lang / f"{book}.json" if path.exists(): with path.open("r", encoding="utf-8") as f: data = json.load(f) break else: return [] by_chapter: dict[int, list[int]] = {} for key in data: parts = key.split(":") if len(parts) == 2: by_chapter.setdefault(int(parts[0]), []).append(int(parts[1])) paragraphs = [] window = 6 for chapter in sorted(by_chapter): verses = sorted(by_chapter[chapter]) for i in range(0, len(verses), window): chunk = verses[i : i + window] refs = [f"{chapter}:{v}" for v in chunk] paragraphs.append({"start": refs[0], "end": refs[-1], "verses": refs}) return paragraphs def main(): parser = argparse.ArgumentParser( description="Fetch and extract pericope data from SBLGNT" ) parser.add_argument( "--book", help="Only process specific book (e.g., 'john')", ) parser.add_argument( "--output", default="data/metadata/pericopes.json", help="Output file path", ) parser.add_argument( "--cache-dir", default=".cache/sblgnt", help="Cache directory for downloaded files", ) parser.add_argument( "--auto-generate", action="store_true", help="Auto-generate pericopes from paragraphs", ) args = parser.parse_args() # Get project root script_dir = Path(__file__).parent project_root = script_dir.parent cache_dir = project_root / args.cache_dir output_path = project_root / args.output # Download SBLGNT xml_path = download_sblgnt_osis(cache_dir) # Extract paragraphs paragraphs = extract_paragraphs_from_osis(xml_path, args.book) print(f"\nExtracted paragraphs from {len(paragraphs)} books:") for book, paras in paragraphs.items(): print(f" {book}: {len(paras)} paragraphs") # Fallback: OSIS extraction found nothing for the requested book # (apparatus-only download, or an OT book not in SBLGNT). if args.book and args.book not in paragraphs: fallback = generate_paragraphs_from_sources( args.book, project_root / "data" / "sources" ) if fallback: print( f"OSIS had no paragraphs for {args.book}; generated " f"{len(fallback)} windowed pseudo-paragraphs from local sources" ) paragraphs[args.book] = fallback # Merge with existing pericopes result = merge_with_pericopes(paragraphs, output_path) # Optionally auto-generate pericopes if args.auto_generate: for book_name, book_paras in paragraphs.items(): if book_name in result["books"]: existing = result["books"][book_name].get("pericopes", []) if not existing: print(f"Auto-generating pericopes for {book_name}...") result["books"][book_name]["pericopes"] = ( generate_pericopes_from_paragraphs(book_paras, book_name) ) # Save output_path.parent.mkdir(parents=True, exist_ok=True) with output_path.open("w", encoding="utf-8") as f: json.dump(result, f, ensure_ascii=False, indent=2) print(f"\nSaved to: {output_path}") if __name__ == "__main__": main()