signal-bot/tool/gen_popes.py
2026-08-30 21:28:41 +02:00

194 lines
7.9 KiB
Python
Executable File

#!/usr/bin/env python3
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Regenerate ``signal_bot/popes.json`` from the Polish Wikipedia pope list.
Run this by hand when a new pope is elected; the output is committed, so the
bot never touches the network to learn its own version name.
The ``Lp.`` column of *Poczet papieży* is the authority for the ordinal. It is
the reason this is scraped rather than recalled: antipopes, Benedykt IX's three
separate reigns and the Stefan II question all shift the count, and being wrong
by one silently moves Jan Paweł II off 2.13.7.
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
from typing import Final
import httpx
API: Final = (
"https://pl.wikipedia.org/w/api.php?action=parse"
"&page=Poczet%20papie%C5%BCy&prop=wikitext&format=json&formatversion=2"
)
# Wikimedia's policy requires a descriptive agent with a contact URL; the
# generic client default is rate-limited hard and may be blocked outright.
USER_AGENT: Final = "signal-bot popes generator (https://github.com/kuhyx/signal-bot)"
# One row per pope: "|<n>." (later rows add a second, per-era numbering that
# is deliberately ignored), then the linked name, then the portrait file.
_ROW: Final = re.compile(r"\|(\d+)\.(?: */ *'''\d+\.''')?\s*\n\|\[?\[?([^\]|\n]+)")
_PORTRAIT: Final = re.compile(r"\[\[Plik:([^\]|]+)")
# A year is a wiki link whose whole target is digits. Matching bare numbers
# instead catches "100px" from an image and the "31" of "31 stycznia".
_YEARS: Final = re.compile(r"\[\[(\d{1,4})\]\]")
# The list spells the first entry "Piotr"; kuhy asked for his full title.
FIRST_POPE: Final = "Święty Piotr Apostoł"
# Ordinal -> Latin name, for any row whose Latin cell stops being parseable.
# Empty as of 2026-08-30, when all 267 came out of the table clean; it exists
# so a future gap is a two-line edit, and _rejection refuses to ship without.
LATIN_OVERRIDES: Final[dict[int, str]] = {}
EXPECTED_TOTAL: Final = 267
JOHN_PAUL_II: Final = 264
def _clean(name: str) -> str:
"""Strip the wiki disambiguation suffix from a linked page title."""
return re.sub(r"\s*\(papież\)", "", name).strip()
def _tidy(cell: str) -> str:
"""Flatten one wikitext table cell to plain text."""
text = re.sub(r"<ref[^>]*>.*?</ref>", "", cell, flags=re.DOTALL)
text = re.sub(r"<ref[^>]*/>", "", text)
# The regnal number sits on its own line: "Stephanus<br />Nonus".
text = re.sub(r"<br\s*/?>", " ", text)
text = text.replace(chr(39) * 3, "").replace(chr(39) * 2, "")
text = re.sub(r"\[\[(?:[^\]|]*\|)?([^\]|]+)\]\]", r"\1", text)
return " ".join(text.split())
def _latin(row: str) -> str:
"""Return the row's Latin name, from the cell after the portrait.
Taken from this table rather than from la.wikipedia's article titles,
which number several popes differently: the sitelink for #154 is
"Stephanus X" while every other column here calls him the ninth. A
caption reading "Stefan IX -- Stephanus X" reads as an off-by-one, and
this column ("Stephanus Nonus") cannot disagree with the name beside it.
"""
cells = row.lstrip("\n").split("\n|")
for index, cell in enumerate(cells):
if "[[Plik:" in cell and index + 1 < len(cells):
return _tidy(cells[index + 1])
return ""
def _reign(row: str) -> str:
"""Return a row's reign years as ``start-end``, or ``start-`` if reigning.
The reign is the row's last cell -- but a row that ends a wikitable runs
on into the prose after ``|}``, which is how Jan Pawel II first came out
as "100" (an image width) and Leon XIV as "2005-069" (a footnote).
"""
cell = row.split("\n|}", maxsplit=1)[0].rsplit("\n|", 1)[-1]
years = _YEARS.findall(cell)
if not years:
return ""
if len(years) == 1:
return f"{years[0]}-" if cell.rstrip().endswith(("-", "\u2013")) else years[0]
return f"{years[0]}-{years[-1]}"
def parse(wikitext: str) -> list[dict[str, object]]:
"""Return one entry per pope, ordered by the list's own Lp. column."""
popes: dict[int, dict[str, object]] = {}
for row in re.split(r"\n\|-", wikitext):
match = _ROW.match(row.lstrip("\n"))
portrait = _PORTRAIT.search(row)
if match is None or portrait is None:
continue
ordinal = int(match.group(1))
popes.setdefault(
ordinal,
{
"ordinal": ordinal,
"name": _clean(match.group(2)),
# The raw link target, disambiguator and all: it is both the
# pl.wikipedia URL slug and the key the la sitelink is under.
# _clean() drops "(papież)", which loses 72 of the 267.
"title": match.group(2).strip(),
"reign": _reign(row),
"portrait": portrait.group(1).strip(),
"latin": _latin(row),
},
)
return [popes[n] for n in sorted(popes)]
def _rejection(popes: list[dict[str, object]]) -> str:
"""Return why this list must not be written, or an empty string."""
if [p["ordinal"] for p in popes] != list(range(1, len(popes) + 1)):
return "ordinals are not contiguous from 1"
if len(popes) != EXPECTED_TOTAL:
return f"got {len(popes)} popes, expected {EXPECTED_TOTAL}"
if popes[JOHN_PAUL_II - 1]["name"] != "Jan Paweł II":
return f"#{JOHN_PAUL_II} is not Jan Paweł II"
blank = [p["ordinal"] for p in popes if not p.get("latin")]
if blank:
return f"no Latin name for {blank}; add them to LATIN_OVERRIDES"
# title carries the "(papież)" disambiguator and is what both the article
# URL and the langlink lookup key on. An empty one 404s in the group.
untitled = [p["ordinal"] for p in popes if not p.get("title")]
if untitled:
return f"no article title for {untitled}"
return ""
def _drift(popes: list[dict[str, object]], previous: Path) -> str:
"""Return how this list contradicts the committed one, or "".
The version ladder is built from these names: if a regeneration quietly
renamed pope 8, the next release would announce the wrong pope into every
watched group. Growth is fine -- a conclave adds an ordinal -- but no
already-shipped field may change value.
"""
if not previous.exists():
return ""
old = json.loads(previous.read_text(encoding="utf-8"))["popes"]
fresh = {p["ordinal"]: p for p in popes}
for entry in old:
now = fresh.get(entry["ordinal"])
if now is None:
return f"#{entry['ordinal']} vanished from the list"
# latin is in the tuple because latin_name() derives the release name
# from it: an upstream edit there renames a shipped version.
fields = ("name", "reign", "portrait", "latin")
changed = [k for k in fields if entry.get(k, now[k]) != now[k]]
if changed:
return f"#{entry['ordinal']} changed {changed}: {entry} -> {now}"
return ""
def main() -> int:
"""Write popes.json, refusing to write anything that fails its own checks."""
response = httpx.get(API, headers={"User-Agent": USER_AGENT}, timeout=60.0)
response.raise_for_status()
popes = parse(str(response.json()["parse"]["wikitext"]))
if popes:
popes[0]["name"] = FIRST_POPE
for pope in popes:
override = LATIN_OVERRIDES.get(int(str(pope["ordinal"])))
if override:
pope["latin"] = override
out = Path(__file__).resolve().parent.parent / "signal_bot" / "popes.json"
rejection = _rejection(popes) or _drift(popes, out)
if rejection:
sys.stderr.write(f"refusing to write: {rejection}\n")
return 1
document = {"source": API, "popes": popes}
out.write_text(json.dumps(document, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
sys.stdout.write(f"wrote {len(popes)} popes to {out}\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())