mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-11 13:47:48 +01:00
Some checks are pending
CI / check (push) Waiting to run
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
191 lines
7.7 KiB
Python
Executable File
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())
|