diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-28 10:20:23 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-28 10:20:23 +0200 |
| commit | a92bb734f2a1780431fd2fbab65aa814200397f7 (patch) | |
| tree | 47979d6ad701f245b916b087bd561b441cfac2b2 /scripts | |
| parent | 97f704b659ea6c72c4aa354378b347283ecc5cac (diff) | |
| download | lectio-a92bb734f2a1780431fd2fbab65aa814200397f7.tar.gz lectio-a92bb734f2a1780431fd2fbab65aa814200397f7.zip | |
feat(of): complete Sunday lectionary from catholic-resources.org (restore 2nd readings)
The niedziela harvest had dropped the SECOND reading on 271 of ~299 Sunday-cycle
entries (a Sunday Mass is first/psalm/second/gospel). scripts/genlect-of-sundays-cr.py
re-sources the temporal Sunday & solemnity lectionary (Ordinary Time, Advent,
Christmas, Lent, Easter, Trinity, Corpus Christi, Sacred Heart, Christ the King;
all three cycles A/B/C) from catholic-resources.org, keyed by liturgical position,
and merges the complete readings into of-lectionary.ini.
- Also corrects ~6 contaminated Sunday first readings the niedziela by-date
harvest had picked up (ordinary-sunday-21-B had a Marian Prov 8; advent-2-A had
the Immaculate Conception's Gen 3; Christ the King B/C, Trinity C, Pentecost,
Ascension/7th-of-Easter were wrong).
- Content-validated: every changed first reading verified against the old one
(all changes are correct fixes or the identical passage, book spelling aside).
- Handles source quirks: hyphen/en-dash/ABC cycle markers, verbose Year-A
solemnity names, 'opt:' cycle-proper alternates, '[2025: Corpus Christi]'
displacement notes appended to names, Palm Sunday (cycle-independent
first/second, per-cycle Passion gospel kept), and abbreviation variants
(Jon/Is/Mt/Act/Zac...).
157/157 Sundays render first+psalm+second+gospel across 2026-2028. Adds
TestOFSundayCompleteReadings, credits the source in NOTICE, bumps to 0.42.0.
Diffstat (limited to 'scripts')
| -rw-r--r-- | scripts/__pycache__/genlect-of-sundays-cr.cpython-313.pyc | bin | 0 -> 15762 bytes | |||
| -rw-r--r-- | scripts/genlect-of-sundays-cr.py | 182 |
2 files changed, 182 insertions, 0 deletions
diff --git a/scripts/__pycache__/genlect-of-sundays-cr.cpython-313.pyc b/scripts/__pycache__/genlect-of-sundays-cr.cpython-313.pyc Binary files differnew file mode 100644 index 0000000..80579d6 --- /dev/null +++ b/scripts/__pycache__/genlect-of-sundays-cr.cpython-313.pyc diff --git a/scripts/genlect-of-sundays-cr.py b/scripts/genlect-of-sundays-cr.py new file mode 100644 index 0000000..84dd3f8 --- /dev/null +++ b/scripts/genlect-of-sundays-cr.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""Re-source the Ordinary Form SUNDAY & temporal-solemnity lectionary (the 3-year +A/B/C cycle) from catholic-resources.org (Fr. Felix Just, S.J.). + +The niedziela harvest dropped the SECOND reading on 271 of ~299 Sunday-cycle +entries (Sunday Mass = first/psalm/second/gospel). catholic-resources.org gives +the complete readings, keyed by liturgical position. This replaces the temporal +Sunday and temporal-solemnity entries (Ordinary Time, Advent, Christmas, Lent, +Easter, Trinity, Corpus Christi, Sacred Heart, Christ the King). Sanctoral +solemnities/feasts (All Saints, the Assumption, ...) are NOT touched here -- they +carry inline readings in roman-calendar.ini. Citations only; see NOTICE. + +Requires network. From the repo root: python3 scripts/genlect-of-sundays-cr.py +Rewrites the matching <slug>-<A|B|C> entries in internal/caldata/of-lectionary.ini. +""" +import re, sys, html, urllib.request + +INI = "internal/caldata/of-lectionary.ini" +PAGES = ["OrdinaryA", "OrdinaryB", "OrdinaryC", "Advent", "Christmas", "Lent", "Easter", "Solemnities"] +BASE = "https://www.catholic-resources.org/Lectionary/1998USL-{}.htm" + +BOOK = {} +def reg(full, *al): + for a in al: BOOK[a.lower().replace(".", "").strip()] = full +reg("Genesis","gen");reg("Exodus","exod","ex");reg("Leviticus","lev");reg("Numbers","num") +reg("Deuteronomy","deut");reg("Joshua","josh");reg("Judges","judg");reg("Ruth","ruth") +reg("1 Samuel","1 sam");reg("2 Samuel","2 sam");reg("1 Kings","1 kgs");reg("2 Kings","2 kgs") +reg("1 Chronicles","1 chr");reg("2 Chronicles","2 chr");reg("Ezra","ezra");reg("Nehemiah","neh") +reg("Tobit","tob");reg("Judith","jdt");reg("Esther","esth");reg("1 Maccabees","1 macc");reg("2 Maccabees","2 macc") +reg("Job","job");reg("Psalms","ps","pss");reg("Proverbs","prov");reg("Ecclesiastes","eccl") +reg("Song of Solomon","song","cant");reg("Wisdom","wis");reg("Sirach","sir") +reg("Isaiah","isa");reg("Jeremiah","jer");reg("Lamentations","lam");reg("Baruch","bar") +reg("Ezekiel","ezek");reg("Daniel","dan");reg("Hosea","hos");reg("Joel","joel");reg("Amos","amos") +reg("Obadiah","obad");reg("Jonah","jonah");reg("Micah","mic","micah");reg("Nahum","nah");reg("Habakkuk","hab") +reg("Zephaniah","zeph");reg("Haggai","hag");reg("Zechariah","zech");reg("Malachi","mal") +reg("Matthew","matt","mat");reg("Mark","mark");reg("Luke","luke");reg("John","john") +reg("The Acts","acts");reg("Romans","rom");reg("1 Corinthians","1 cor");reg("2 Corinthians","2 cor") +reg("Galatians","gal");reg("Ephesians","eph");reg("Philippians","phil");reg("Colossians","col") +reg("1 Thessalonians","1 thess");reg("2 Thessalonians","2 thess");reg("1 Timothy","1 tim");reg("2 Timothy","2 tim") +reg("Titus","titus");reg("Philemon","phlm");reg("Hebrews","heb");reg("James","jas") +reg("1 Peter","1 pet");reg("2 Peter","2 pet");reg("1 John","1 john");reg("2 John","2 john");reg("3 John","3 john") +reg("Jude","jude");reg("Revelation","rev","apoc") +# CR Sunday-page abbreviation variants (the Sunday tables abbreviate more tersely) +reg("Jonah","jon");reg("The Acts","act");reg("Hebrews","hebr");reg("Isaiah","is") +reg("John","jn");reg("Matthew","mt");reg("Zechariah","zac");reg("Song of Solomon","songs") +reg("1 Peter","1 petr");reg("2 Maccabees","2 mac");reg("Ezekiel","ez") +# also accept a spelled-out book name as its own alias (some cells use full names) +for _full in list(set(BOOK.values())): + BOOK.setdefault(_full.lower(), _full) + +def clean_cite(raw): + s = html.unescape(raw or "") + s = re.sub(r"[–—]", "-", s) + s = re.sub(r"\([^)]*\)", "", s) # drop parenthetical notes: (#740), (cited…), (diff), (new)… + s = re.sub(r"^(opt\.?:|optional:|\d+\))\s*", "", s.strip(), flags=re.I) # cycle-proper "opt:" alternate / numbered option + s = re.split(r"\s+or\b", s, maxsplit=1)[0] + s = re.sub(r"^(cf\.|see)\s+", "", s.strip(), flags=re.I).strip().strip(";,").strip() + if not s or s in (".", "x", "-") or s.startswith("[") or s.startswith("("): + return "" + m = re.match(r"((?:[1-4]\s+)?[A-Za-z][A-Za-z]*\.?)\s*(.*)", s) + if not m: + return "" + full = BOOK.get(m.group(1).lower().replace(".", "").strip()) + if not full: + return "" + return f"{full} {re.sub(r',\s+', ',', m.group(2)).replace(' ', '')}".strip() + +def slug_of(name): + n = re.sub(r"\s+", " ", name.lower()) + if "vigil" in n: # a Sunday's vigil Mass -> use the day Mass row instead + return None + # specific solemnity phrases FIRST (the Year-A rows are verbose, e.g. "Friday + # after the Second Sunday after Pentecost: ... Sacred Heart", so a loose + # "pentecost"/"trinity" test would mis-fire). + if "sacred heart" in n: return "sacred-heart" + if "body and blood" in n or "corpus christi" in n: return "corpus-christi" + if "most holy trinity" in n or "trinity sunday" in n: return "trinity-sunday" + if "christ the king" in n or ("king of the universe" in n): return "christ-the-king" + if "holy family" in n: return "holy-family" + if "baptism of the lord" in n: return "baptism-of-the-lord" + if "epiphany of the lord" in n: return "epiphany" + if "mother of god" in n: return "mary-mother-of-god-octave-of-christmas" + if "ascension of the lord" in n: return "ascension" + if "day of pentecost" in n or "pentecost sunday" in n or n.startswith("pentecost"): return "pentecost" + m = re.search(r"(\d+)\w* sunday in ordinary time", n) + if m: return f"ordinary-sunday-{m.group(1)}" + m = re.search(r"(\d+)\w* sunday of advent", n) + if m: return f"advent-sunday-{m.group(1)}" + m = re.search(r"(\d+)\w* sunday of lent", n) + if m: return f"lent-sunday-{m.group(1)}" + m = re.search(r"(\d+)\w* sunday of easter", n) + if m: + k = int(m.group(1)) + return "easter-sunday" if k == 1 else "easter-octave-sun" if k == 2 else f"easter-sunday-{k}" + if "palm sunday" in n or "passion sunday" in n: return "palm-sunday" + if "resurrection of the lord" in n: return "easter-sunday" # Mass of Easter Day + if "second sunday after christmas" in n or "sunday within the octave of christmas" in n: + return "christmas-sunday-sun" + return None # Nativity Day masses, Triduum, etc. -> not a cycle-Sunday + +def strip_tags(s): + return html.unescape(re.sub(r"<[^>]+>", "", s)).replace("\xa0", " ").replace("\n", " ").strip() + +def scrape_page(name): + req = urllib.request.Request(BASE.format(name), headers={"User-Agent": "Mozilla/5.0 (X11; Linux) lectio"}) + h = urllib.request.urlopen(req, timeout=60).read().decode("utf-8", "replace") + out = {} + for tr in re.split(r"<tr\b", h): + c = [strip_tags(x) for x in re.findall(r"<td\b[^>]*>(.*?)</td>", tr, re.S)] + if len(c) < 8 or c[0] == "Date": + continue + # a name may carry an appended "[2025: Corpus Christi…]" note (this Sunday + # is displaced that year); strip a bracketed note that follows real text so + # slug_of matches the Sunday, not a word inside the note. + name = re.sub(r"(?<=\S)\s*\[[^\]]*\]", "", c[2].replace("\r", " ")) + cyc = re.search(r"[-–—]\s*(ABC|[ABC])\b", name) # hyphen OR en/em dash; ABC = all cycles + slug = slug_of(name) + if not slug: + continue + parts = {"first": clean_cite(c[3]), "psalm": clean_cite(c[4]), + "second": clean_cite(c[5]), "gospel": clean_cite(c[7])} + if not parts["first"] and not parts["second"]: + continue # a procession/notes row with no Mass readings + if slug == "palm-sunday": + # Palm Sunday's Mass gives cycle-independent first/psalm/second; the + # gospel (the Passion) is per cycle, so keep whatever lectio already + # has. Apply the readings to all three cycles, without a gospel. + for cy in ("A", "B", "C"): + out[f"palm-sunday-{cy}"] = {**parts, "gospel": ""} + continue + if not cyc: + continue + if cyc.group(1) == "ABC": + for cy in ("A", "B", "C"): + out.setdefault(f"{slug}-{cy}", parts) # ABC only fills a cycle not given explicitly + else: + out[f"{slug}-{cyc.group(1)}"] = parts # a specific cycle row always wins + return out + +def firstbook(c): # book + chapter, for validation + m = re.match(r"((?:[1-4] )?[A-Za-z ]+?) (\d+)", c or "") + return f"{m.group(1)} {m.group(2)}" if m else (c or "") + +def main(): + cr = {} + for p in PAGES: + d = scrape_page(p) + sys.stderr.write(f"{p}: {len(d)} mapped Sunday entries\n") + cr.update(d) + text = open(INI).read() + head = text[:text.index("\n[")] + blocks = {} + order = [] + for b in re.split(r"\n(?=\[)", text[text.index("\n[") + 1:]): + m = re.match(r"\[(.+?)\]", b) + key = m.group(1) if m else b + blocks[key] = b.rstrip("\n"); order.append(key) + + changed = firstdiff = added = 0 + for key, p in cr.items(): + if not (p["second"] or p["first"]): # nothing useful to merge + continue + old = dict(re.findall(r"^(\w+)\s*=\s*(.+)$", blocks.get(key, ""), re.M)) + if old.get("first") and p["first"] and firstbook(old["first"]) != firstbook(p["first"]): + firstdiff += 1 + sys.stderr.write(f" ~ {key}: first {old['first']!r} -> {p['first']!r}\n") + if "second" not in old and p["second"]: + changed += 1 + merged = {part: (p[part] or old.get(part, "")) for part in ("first", "psalm", "second", "gospel")} + if not (merged["first"] and merged["gospel"]): # never write an incomplete Sunday + continue + block = "[" + key + "]\n" + "\n".join(f"{part} = {merged[part]}" + for part in ("first", "psalm", "second", "gospel") if merged[part]) + if key not in blocks: + order.append(key); added += 1 + blocks[key] = block + open(INI, "w").write(head.rstrip("\n") + "\n\n" + "\n\n".join(blocks[k] for k in order) + "\n") + sys.stderr.write(f"\nmerged Sunday entries; {changed} gained a 2nd reading, {added} new, " + f"{firstdiff} first-reading book changes (mapping check)\n") + +if __name__ == "__main__": + main() |
