Files
bible/extract_bible.py
T
2026-09-02 18:24:01 -04:00

606 lines
21 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Extract the eBible.org HTML Bible export into structured JSON.
The source is a flat directory of generated chapter files (``JHN03.htm``) plus
per-book and site index pages. Each chapter file is a flat sequence of
``<div class='...'>`` blocks whose class is the USFM paragraph marker (``p``,
``q2``, ``s``, ...), with verse numbers, footnote markers and character styles
as inline spans. Footnotes and cross references are collected in a trailing
``<div class="footnote">``.
This script turns that into a JSON tree of blocks and inline nodes, so the
markup can be re-rendered from templates rather than scraped again. Navigation
lists and the copyright footer are dropped; footnotes are kept.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
from html.parser import HTMLParser
# Chapter files are a three-character book code plus a zero-padded chapter
# number (three digits for Psalms).
CHAPTER_RE = re.compile(r"^([0-9A-Z]{3})(\d{2,3})\.htm$")
# Links between chapters: "JOB05.htm#V13", "JHN03.htm", or a bare "#V7".
HREF_RE = re.compile(r"^(?:([0-9A-Z]{3})(\d{2,3})\.htm)?(?:#V(\d+))?$")
# Block-level div classes -> semantic block type.
BLOCK_TYPES = {
"p": "paragraph", # normal prose paragraph
"m": "paragraph", # continuation, no first-line indent
"nb": "paragraph", # no break with previous
"pi": "paragraph", # indented
"mi": "paragraph", # indented, no first-line indent
"pc": "paragraph", # centered
"ip": "paragraph", # introduction paragraph
"q": "line", # poetry line
"q2": "line",
"q3": "line",
"qs": "line", # Selah
"b": "break", # blank line between stanzas
"d": "title", # descriptive title (psalm superscription, acrostic letter)
"s": "heading", # section heading
"ms": "heading", # major section heading ("BOOK 3")
"is": "heading", # introduction heading
"sp": "speaker", # speaker label (Song of Solomon)
"li": "item", # list item
"ili": "item", # introduction list item (glossary entries)
"mt": "booktitle",
"mt2": "booktitle",
"mt3": "booktitle",
}
# Nesting depth for poetry lines and indented paragraphs.
INDENT = {"q": 1, "q2": 2, "q3": 3, "qs": 3, "pi": 1, "mi": 1, "li": 1, "ili": 1}
# Heading / title rank.
LEVEL = {"mt": 1, "mt2": 2, "mt3": 3, "ms": 1, "s": 2, "is": 2}
# Character-style spans -> inline node type. ``None`` means unwrap the span and
# keep its children inline (``ft`` is just "footnote body text").
INLINE = {
"wj": "wj", # words of Jesus
"k": "keyword", # glossary keyword
"bk": "bookref", # book name cited in a note
"fq": "fq", # the quoted words the footnote is about
"fqa": "fqa", # an alternative rendering
"fl": "fl", # footnote label
"fine": "fine",
"xt": "xt", # cross-reference target
"ft": None,
}
# Divs whose entire subtree is navigation or licensing, not content.
DROP_CLASSES = {"copyright", "tnav", "vnav", "popup", "toc", "toc1", "toc2"}
def parse_href(href):
"""Turn an intra-corpus link into a structured reference, or None."""
m = HREF_RE.match(href or "")
if not m or not any(m.groups()):
return None
book, chapter, verse = m.groups()
ref = {}
if book:
ref["book"] = book
ref["chapter"] = int(chapter)
if verse:
ref["verse"] = int(verse)
return ref or None
# --------------------------------------------------------------------------
# Whitespace
# --------------------------------------------------------------------------
def normalize(nodes):
"""Collapse whitespace across the whole inline tree, in document order.
Runs of whitespace become a single space and spaces are not duplicated
across element boundaries, so "word</span> <span>next" keeps exactly one.
"""
state = {"prev_space": True} # True at the start suppresses a leading space
def walk(lst):
out = []
for node in lst:
if node["t"] == "text":
text = re.sub(r"[\s ]+", " ", node["v"])
if state["prev_space"]:
text = text.lstrip(" ")
if text:
state["prev_space"] = text.endswith(" ")
out.append({"t": "text", "v": text})
elif "c" in node:
node["c"] = walk(node["c"])
if node["c"]:
out.append(node)
else:
state["prev_space"] = False
out.append(node)
return out
result = walk(nodes)
rstrip_last(result)
return result
def rstrip_last(nodes):
"""Drop the trailing space on a tree's final text node.
Only a genuinely trailing node is stripped. A space before a block's
closing verse or note marker separates it from the preceding word and
has to survive, so the walk stops at the last node rather than looking
past markers for something to strip.
"""
if not nodes:
return False
node = nodes[-1]
if node["t"] == "text":
node["v"] = node["v"].rstrip()
if not node["v"]:
nodes.pop()
return rstrip_last(nodes)
return True
if "c" in node:
return rstrip_last(node["c"])
return False
def plain_text(nodes, skip=("noteref",)):
"""Flatten an inline tree to readable text, dropping note markers."""
parts = []
for node in nodes:
if node["t"] in skip:
continue
if node["t"] == "text":
parts.append(node["v"])
elif node["t"] == "br":
parts.append(" ")
elif "c" in node:
parts.append(plain_text(node["c"], skip))
return "".join(parts)
# --------------------------------------------------------------------------
# Chapter parser
# --------------------------------------------------------------------------
class ChapterParser(HTMLParser):
"""Parse one chapter file into blocks + notes.
Content is appended to ``self.sink``, the inline-node list currently being
filled. A stack of frames records what to restore (and what to finish) as
each element closes; ``sink is None`` discards text, which is how
navigation and the copyright footer are dropped.
"""
def __init__(self):
super().__init__(convert_charrefs=True)
self.blocks = []
self.notes = []
self.titles = [] # mt/mt2/mt3, hoisted to the book
self.chapter_label = None
self.is_toc = False # a chapter-0 navigation page
self.in_main = False
self.stack = []
self.sink = None
# -- frame helpers ----------------------------------------------------
def _push(self, tag, sink, on_close=None, **extra):
frame = {"tag": tag, "prev": self.sink, "close": on_close}
frame.update(extra)
self.stack.append(frame)
self.sink = sink
return frame
def _emit(self, node):
if self.sink is not None:
self.sink.append(node)
# -- HTMLParser hooks -------------------------------------------------
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
classes = (attrs.get("class") or "").split()
cls = classes[0] if classes else ""
if tag in ("br", "hr", "img", "meta", "link", "input"):
if tag == "br":
self._emit({"t": "br"})
return
if not self.in_main:
# Everything before <div class="main"> is <head> and top nav.
if tag == "div" and "main" in classes:
self.in_main = True
self._push(tag, None)
return
if cls in DROP_CLASSES:
if cls in ("toc", "toc1", "toc2"):
self.is_toc = True
self._push(tag, None)
return
if tag == "div" and cls == "chapterlabel":
buf = []
self._push(tag, buf, self._close_chapter_label, nodes=buf)
return
if tag == "div" and cls in BLOCK_TYPES:
block = {"type": BLOCK_TYPES[cls], "style": cls, "content": []}
if cls in INDENT:
block["indent"] = INDENT[cls]
if cls in LEVEL:
block["level"] = LEVEL[cls]
self._push(tag, block["content"], self._close_block, block=block)
return
if tag == "p" and cls in ("f", "x"):
note = {
"id": attrs.get("id"),
"kind": "footnote" if cls == "f" else "xref",
"marker": None,
"ref": None,
"verse": None,
"content": [],
}
self._push(tag, note["content"], self._close_note, note=note)
return
if tag == "span" and "verse" in classes:
buf = []
self._push(tag, buf, self._close_verse, nodes=buf)
return
if tag == "span" and cls == "notemark":
# The note's own marker inside the footnote list.
buf = []
self._push(tag, buf, self._close_note_marker, nodes=buf)
return
if tag == "a" and cls == "notemark":
# An in-text marker linking down to the note; its nested
# <span class="popup"> duplicates the note body and is dropped.
node = {"t": "noteref", "id": (attrs.get("href") or "").lstrip("#")}
self._emit(node)
buf = []
self._push(tag, buf, self._close_noteref, node=node, nodes=buf)
return
if tag == "a" and cls == "notebackref":
buf = []
self._push(tag, buf, self._close_backref, nodes=buf)
return
if tag == "span" and cls in INLINE:
kind = INLINE[cls]
if kind is None:
self._push(tag, self.sink) # unwrap
else:
node = {"t": kind, "c": []}
self._emit(node)
self._push(tag, node["c"])
return
if tag == "a":
ref = parse_href(attrs.get("href"))
if ref is not None and self.sink is not None:
node = {"t": "ref", "c": []}
node.update(ref)
self._emit(node)
self._push(tag, node["c"])
else:
self._push(tag, self.sink)
return
# Anything else (nested spans, stray markup) is transparent.
self._push(tag, self.sink)
def handle_endtag(self, tag):
if not self.in_main or not self.stack:
return
for i in range(len(self.stack) - 1, -1, -1):
if self.stack[i]["tag"] == tag:
break
else:
return # unmatched close tag; ignore
while len(self.stack) > i:
frame = self.stack.pop()
self.sink = frame["prev"]
if frame["close"]:
frame["close"](frame)
if tag == "div" and not self.stack:
self.in_main = False # <div class="main"> closed
def handle_data(self, data):
if self.sink is not None and data:
self.sink.append({"t": "text", "v": data})
# -- frame close handlers ---------------------------------------------
def _close_chapter_label(self, frame):
self.chapter_label = plain_text(frame["nodes"]).strip()
def _close_block(self, frame):
block = frame["block"]
block["content"] = normalize(block["content"])
if block["type"] == "booktitle":
if block["content"]:
self.titles.append({
"style": block["style"],
"level": block.get("level"),
"text": plain_text(block["content"]),
})
return
if block["type"] == "break":
block.pop("content")
self.blocks.append(block)
return
if block["content"]:
self.blocks.append(block)
def _close_note(self, frame):
note = frame["note"]
note["content"] = normalize(note["content"])
note["text"] = plain_text(note["content"])
self.notes.append(note)
def _close_verse(self, frame):
label = plain_text(frame["nodes"]).replace(" ", " ").strip()
node = {"t": "verse", "label": label}
m = re.match(r"^(\d+)(?:[-–](\d+))?$", label)
if m:
node["n"] = int(m.group(1))
if m.group(2):
node["end"] = int(m.group(2))
self._emit(node)
def _close_note_marker(self, frame):
marker = plain_text(frame["nodes"]).strip()
for f in reversed(self.stack):
if "note" in f:
f["note"]["marker"] = marker
return
def _close_noteref(self, frame):
frame["node"]["marker"] = plain_text(frame["nodes"]).strip()
def _close_backref(self, frame):
text = plain_text(frame["nodes"]).strip()
for f in reversed(self.stack):
if "note" in f:
f["note"]["ref"] = text
m = re.search(r":(\d+)", text)
if m:
f["note"]["verse"] = int(m.group(1))
return
# --------------------------------------------------------------------------
# Corpus
# --------------------------------------------------------------------------
class IndexParser(HTMLParser):
"""Read book order, display names and canon section from index.htm."""
# Link class on the site index marks which part of the canon a book is in.
SECTIONS = {"oo": "ot", "nn": "nt", "aa": "deuterocanon", "xx": "other"}
def __init__(self):
super().__init__(convert_charrefs=True)
self.books = [] # [{code, name, section, order}]
self.title = None
self._link = None
self._in_title = False
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
if tag == "title":
self._in_title = True
elif tag == "a":
m = CHAPTER_RE.match(os.path.basename(attrs.get("href") or ""))
section = self.SECTIONS.get((attrs.get("class") or "").strip())
if m and section:
self._link = {
"code": m.group(1),
"section": section,
"order": len(self.books) + 1,
"name": "",
}
def handle_endtag(self, tag):
if tag == "title":
self._in_title = False
elif tag == "a" and self._link:
self._link["name"] = self._link["name"].strip()
self.books.append(self._link)
self._link = None
def handle_data(self, data):
if self._in_title and self.title is None:
self.title = data.strip()
elif self._link is not None:
self._link["name"] += data
def verse_index(blocks):
"""Flat {verse label: text} map, for search and direct verse lookup.
Verse markers are inline, so a verse's text runs from its marker to the
next one and may span several blocks (a poetry couplet, for instance).
"""
verses = {}
current = None
parts = []
def flush():
if current is not None:
text = re.sub(r"\s+", " ", "".join(parts)).strip()
if text:
verses[current] = (verses.get(current, "") + " " + text).strip()
def walk(nodes):
nonlocal current
for node in nodes:
if node["t"] == "verse":
flush()
parts.clear()
current = node["label"]
elif node["t"] == "noteref":
continue
elif node["t"] == "text":
parts.append(node["v"])
elif node["t"] == "br":
parts.append(" ")
elif "c" in node:
walk(node["c"])
for block in blocks:
content = block.get("content")
if not content:
continue
walk(content)
parts.append(" ") # blocks do not run words together
flush()
return verses
def parse_chapter(path):
parser = ChapterParser()
with open(path, encoding="utf-8-sig") as fh:
parser.feed(fh.read())
parser.close()
return parser
def extract(source_dir, with_verse_text=True):
index_path = os.path.join(source_dir, "index.htm")
index = IndexParser()
with open(index_path, encoding="utf-8-sig") as fh:
index.feed(fh.read())
meta = {b["code"]: b for b in index.books}
# Group chapter files by book code.
by_book = {}
for name in sorted(os.listdir(source_dir)):
m = CHAPTER_RE.match(name)
if m:
by_book.setdefault(m.group(1), []).append((int(m.group(2)), name))
books = []
skipped = []
for code in sorted(by_book, key=lambda c: (meta.get(c, {}).get("order", 999), c)):
info = meta.get(code, {"code": code, "name": code, "section": "other",
"order": 999})
book = {
"code": code,
"name": info["name"],
"section": info["section"],
"titles": [],
"chapters": [],
}
for number, name in sorted(by_book[code]):
parsed = parse_chapter(os.path.join(source_dir, name))
if parsed.is_toc or not parsed.blocks:
# Chapter-0 files are per-book tables of contents, not text.
skipped.append(name)
continue
if parsed.titles and not book["titles"]:
book["titles"] = parsed.titles
label = parsed.chapter_label
chapter = {
"number": int(label) if label and label.isdigit() else number,
"blocks": parsed.blocks,
"notes": parsed.notes,
}
if with_verse_text:
chapter["verses"] = verse_index(parsed.blocks)
book["chapters"].append(chapter)
if book["chapters"]:
books.append(book)
return {
"translation": {
"id": "engwebu",
"name": index.title or "World English Bible Updated",
"license": "Public Domain",
"source": "eBible.org",
},
"books": books,
}, skipped
def write_split(data, out_dir):
"""One JSON per chapter plus an index, for chapter-at-a-time fetching."""
os.makedirs(out_dir, exist_ok=True)
index = {"translation": data["translation"], "books": []}
for book in data["books"]:
book_dir = os.path.join(out_dir, book["code"])
os.makedirs(book_dir, exist_ok=True)
for chapter in book["chapters"]:
payload = dict(chapter)
payload["book"] = book["code"]
path = os.path.join(book_dir, "%d.json" % chapter["number"])
with open(path, "w", encoding="utf-8") as fh:
json.dump(payload, fh, ensure_ascii=False, separators=(",", ":"))
index["books"].append({
"code": book["code"],
"name": book["name"],
"section": book["section"],
"titles": book["titles"],
"chapters": [c["number"] for c in book["chapters"]],
})
with open(os.path.join(out_dir, "index.json"), "w", encoding="utf-8") as fh:
json.dump(index, fh, ensure_ascii=False, indent=2)
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("source", nargs="?", default="source",
help="directory of eBible .htm files (default: source)")
ap.add_argument("-o", "--output", default="bible.json",
help="output JSON file (default: bible.json)")
ap.add_argument("--split", metavar="DIR",
help="also write one JSON per chapter into DIR")
ap.add_argument("--no-verse-text", action="store_true",
help="omit the flat per-chapter verse text map")
ap.add_argument("--pretty", action="store_true",
help="indent the output JSON")
args = ap.parse_args(argv)
data, skipped = extract(args.source, with_verse_text=not args.no_verse_text)
dump = dict(indent=2) if args.pretty else dict(separators=(",", ":"))
with open(args.output, "w", encoding="utf-8") as fh:
json.dump(data, fh, ensure_ascii=False, **dump)
if args.split:
write_split(data, args.split)
chapters = sum(len(b["chapters"]) for b in data["books"])
notes = sum(len(c["notes"]) for b in data["books"] for c in b["chapters"])
blocks = sum(len(c["blocks"]) for b in data["books"] for c in b["chapters"])
size = os.path.getsize(args.output) / 1e6
print("books %d" % len(data["books"]), file=sys.stderr)
print("chapters %d" % chapters, file=sys.stderr)
print("blocks %d" % blocks, file=sys.stderr)
print("notes %d" % notes, file=sys.stderr)
print("skipped %d (contents pages)" % len(skipped), file=sys.stderr)
print("wrote %s (%.1f MB)" % (args.output, size), file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())