signal-bot/tool/gen_popes.py
Krzysztof kuhy Rudnicki 410dcda5d2
Some checks are pending
CI / check (push) Waiting to run
release: 0.0.7 «Telesfor» — !papież, and the bot can test itself
Adds !papież / !papiez / !pope: a random pope as a photo, captioned with his
Latin name, reign and pl.wikipedia link. popes.json already carried a Commons
portrait filename for all 267 and nothing had ever consumed it.

The Latin name comes from the Latin column of Poczet papieży, not from the
la.wikipedia sitelink. The sitelink resolved for all 267 and was still wrong:
la.wikipedia numbers several popes differently, so #154 came out as
"Stefan IX — Stephanus X" in a single caption, which reads as an off-by-one.
The table's own column says "Stephanus Nonus" and cannot disagree with the
name beside it. LATIN_OVERRIDES ships empty; it is a tripwire, not a backlog.

Portraits are cached on disk under CacheDirectory=signal-bot, named from the
ordinal so no upstream string ever reaches a path, and bounded at 267 entries
so nothing needs evicting. Verified that CacheDirectory wins against
ProtectHome=read-only before relying on it.

Also adds POST /_selftest and scripts/selftest.sh. Signal never delivers an
account its own sends, so no command has ever been exercised end-to-end
without asking a human to type it. This injects a real frame into the live
dispatcher; it is loopback-only and refuses any group not in SELFTEST_GROUPS,
which is empty by default.

Verified against the real endpoints, not just the fake: 267/267 portraits
resolve on Commons, regeneration is byte-identical on every previously
shipped field, and !papież was driven 7 times through the live dispatcher in
testbot, returning distinct popes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
2026-08-30 17:06:25 +02:00

191 lines
7.7 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"
changed = [k for k in ("name", "reign", "portrait") 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())