mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-11 19:14:04 +01:00
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
194 lines
7.9 KiB
Python
Executable File
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())
|