#!/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()