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