feat: add canonical daily Gospel book data
Co-authored-by: rafa <rcalvotorrejon@gmail.com> Signed-off-by: rafa <rcalvotorrejon@gmail.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Parse the InDesign HTML export of *A la fuente cada día*.
|
||||
|
||||
The output is deliberately source-only: it contains no civil-date mapping.
|
||||
That mapping belongs in the separate, reviewable liturgical calendar JSON.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
TITLE_SOLEMNITY = "V-T-tulo-2-l-nea"
|
||||
TITLE_OR_CITATION = "V-T-tulo-L-a-S"
|
||||
GOSPEL = "Sangr-a-2-de-t--independiente"
|
||||
MOTTO = "V-P-rrafo-2"
|
||||
PARAGRAPH = "V-P-rrafo"
|
||||
CITATION_RE = re.compile(r"\((?:Mt|Mc|Lc|Jn)\b[^)]*\)", re.IGNORECASE)
|
||||
MANUAL_CITATIONS = {
|
||||
# Three malformed/omitted references in the InDesign export, resolved from
|
||||
# the printed Gospel text in the same source entry.
|
||||
"Miércoles de la 32ª semana Lc 17,11-19)": "Lc 17,11-19",
|
||||
"22 de Julio": "Jn 20,1-2.11-18",
|
||||
"2 de noviembre": "Jn 11,21-44",
|
||||
}
|
||||
|
||||
|
||||
def text(fragment: str) -> str:
|
||||
"""Return normalized text from the small, well-formed HTML fragments."""
|
||||
fragment = re.sub(r"<[^>]+>", "", fragment)
|
||||
return re.sub(r"\s+", " ", html.unescape(fragment).replace("\xa0", " ")).strip()
|
||||
|
||||
|
||||
def css_base(class_name: str) -> str:
|
||||
return class_name.split(" ", 1)[0]
|
||||
|
||||
|
||||
def is_citation(value: str) -> bool:
|
||||
return bool(CITATION_RE.fullmatch(value.strip()))
|
||||
|
||||
|
||||
def clean_citation(value: str) -> str:
|
||||
match = CITATION_RE.search(value)
|
||||
return (match.group(0) if match else value).strip().removeprefix("(").removesuffix(")").strip()
|
||||
|
||||
|
||||
def new_entry(title: str, kind: str) -> dict[str, object]:
|
||||
return {
|
||||
"source_index": 0,
|
||||
"kind": kind,
|
||||
"title": title,
|
||||
"citation": "",
|
||||
"gospel": "",
|
||||
"motto": "",
|
||||
"paragraphs": [],
|
||||
}
|
||||
|
||||
|
||||
def set_citation_from(value: str, entry: dict[str, object]) -> None:
|
||||
"""Capture a reference embedded in a weekday or feast-title line."""
|
||||
if entry["citation"]:
|
||||
return
|
||||
match = CITATION_RE.search(value)
|
||||
if match:
|
||||
entry["citation"] = clean_citation(match.group(0))
|
||||
elif "pasión según" in value.lower():
|
||||
entry["citation"] = value.strip().removeprefix("(").removesuffix(")")
|
||||
|
||||
|
||||
def parse(source: Path) -> list[dict[str, object]]:
|
||||
raw = source.read_text(encoding="utf-8")
|
||||
blocks = re.findall(r'<p class="([^"]+)"[^>]*>(.*?)</p>', raw, re.S)
|
||||
entries: list[dict[str, object]] = []
|
||||
current: dict[str, object] | None = None
|
||||
|
||||
def finish() -> None:
|
||||
nonlocal current
|
||||
if current is not None:
|
||||
current["source_index"] = len(entries) + 1
|
||||
entries.append(current)
|
||||
current = None
|
||||
|
||||
for class_name, fragment in blocks:
|
||||
role, value = css_base(class_name), text(fragment)
|
||||
if not value:
|
||||
continue
|
||||
|
||||
if role == TITLE_SOLEMNITY:
|
||||
finish()
|
||||
current = new_entry(value, "solemnity")
|
||||
set_citation_from(value, current)
|
||||
continue
|
||||
|
||||
if role == TITLE_OR_CITATION and not is_citation(value):
|
||||
# This class is overloaded. After an already complete entry it is
|
||||
# the next weekday title (and also the Holy Family title). Right
|
||||
# after a large title it is instead a rubric such as "INMACULADA"
|
||||
# or "Pasión según Mt", so it must remain part of that entry.
|
||||
if current is None or current["motto"] or current["paragraphs"]:
|
||||
finish()
|
||||
current = new_entry(value, "weekday")
|
||||
set_citation_from(value, current)
|
||||
continue
|
||||
|
||||
if current is None:
|
||||
continue
|
||||
|
||||
if role == TITLE_OR_CITATION and is_citation(value):
|
||||
current["citation"] = clean_citation(value)
|
||||
elif role == TITLE_OR_CITATION:
|
||||
set_citation_from(value, current)
|
||||
elif role == GOSPEL:
|
||||
current["gospel"] = value
|
||||
elif role == MOTTO:
|
||||
current["motto"] = value
|
||||
elif role == PARAGRAPH:
|
||||
# One source inconsistency (Easter VI, cycle A) styles the Gospel as
|
||||
# a normal paragraph. Before the motto, that position is unambiguous.
|
||||
if not current["gospel"] and not current["motto"]:
|
||||
current["gospel"] = value
|
||||
else:
|
||||
current["paragraphs"].append(value)
|
||||
|
||||
finish()
|
||||
for entry in entries:
|
||||
if not entry["citation"] and entry["title"] in MANUAL_CITATIONS:
|
||||
entry["citation"] = MANUAL_CITATIONS[entry["title"]]
|
||||
return entries
|
||||
|
||||
|
||||
def audit(entries: list[dict[str, object]]) -> list[str]:
|
||||
issues: list[str] = []
|
||||
for entry in entries:
|
||||
missing = [field for field in ("citation", "gospel", "motto") if not entry[field]]
|
||||
if not entry["paragraphs"]:
|
||||
missing.append("paragraphs")
|
||||
if missing:
|
||||
issues.append(f"#{entry['source_index']} {entry['title']}: missing {', '.join(missing)}")
|
||||
return issues
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("source", type=Path)
|
||||
parser.add_argument("output", type=Path)
|
||||
args = parser.parse_args()
|
||||
|
||||
entries = parse(args.source)
|
||||
issues = audit(entries)
|
||||
# The earlier exploratory JSON had 504 records, but four were artificial
|
||||
# citation-only splits (Holy Family and Baptism A/B/C). The source has 500
|
||||
# complete, independently publishable comments.
|
||||
if len(entries) != 500:
|
||||
raise SystemExit(f"expected 500 complete entries; got {len(entries)}")
|
||||
if issues:
|
||||
raise SystemExit("source audit failed:\n" + "\n".join(issues))
|
||||
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(entries, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
print(f"wrote {len(entries)} complete entries to {args.output}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user