signal-bot/tool/gen_traps.py
Krzysztof kuhy Rudnicki 14d422295f feat: a word with two meanings gets the one made of steel
!pułapka (and !trap, !sidła, !wnyk, !potrzask, 26 spellings in all) posts a
photograph of the literal device -- a bear trap, a mousetrap, a snare, a fish
weir -- captioned with what Commons says it is.

The pool is 2218 Commons page ids, harvested by tool/gen_traps.py from a
curated seed list and committed. Page ids and not filenames on purpose: a
filename carries photographers' and sitters' names, and one of those
colliding with a label somebody mints from the chat would wedge
check_personal_data.py permanently, over data nobody can edit by hand.
_repo_vocabulary() subtracts aliases.json and nothing else, so it would not
have caught it. An integer cannot carry a name.

Nothing is cached. !papież caches because 267 portraits are a closed set a
group works through; a uniform draw from 2218 warms a cache essentially
never, so this fetches per call the way !kot and !pies do.

What the harvest measured, and now enforces:

- five plausible seed categories do not exist (Snares, Man traps, Hunting
  traps, Wasp traps, Animal trapping). A missing category is an empty answer,
  not an error, so verify_seeds() asks Commons before harvesting and fails
  loudly naming all of them.
- depth three from Category:Fish traps reaches Alfred Sisley's net paintings.
  Depth one, from seeds picked by hand.
- "<thing> in art" categories are paintings: Mousetraps in art put a Robert
  Campin altarpiece in the pool. Pruned by pattern, since Commons mints a new
  one whenever somebody needs it. Booby traps, the Wolfsangel, camera traps
  and animals dying in traps are pruned by name.
- Commons rate-limits hard, so a partial harvest must not be committed
  silently: 429 backs off, and a short pool refuses to write at all.

Descriptions are HTML and sometimes say nothing, so the tags come out, the
Fisheries records' trailing catalogue list is cut, a description naming only
the country falls back to the filename, and the file's own page is always
linked.

!łapka is not an alias: it resolves to !papa at distance two, which
test_nearest.py fails on, and rightly -- the suggestion would be a coin toss.

signal_bot/commons.py is the imageinfo query both Commons commands now share;
portraits.py keeps its behaviour and its tests unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01416TegJ3sa8xL5qGGgrygP
2026-08-31 18:01:27 +02:00

217 lines
8.3 KiB
Python
Executable File

#!/usr/bin/env python3
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Regenerate ``signal_bot/traps.json`` from Wikimedia Commons.
Run this by hand; the output is committed, so the bot never searches Commons
at the moment somebody types ``!pułapka``.
**The file holds page ids, not filenames, and that is load-bearing.** Commons
filenames are free text carrying people's names -- the harvest turns up things
like ``"Fishing-Boat with Fishermen" - Vasileios Chatzis.jpg``. Committing two
thousand of those would eventually collide with a label somebody minted from
the chat with ``!add_photo``, and ``scripts/check_personal_data.py`` would then
refuse every commit for good, over data nobody can edit out by hand. An
integer cannot carry a name. ``prop=imageinfo`` accepts ``pageids=`` and puts
``thumburl`` at the same place ``titles=`` does, so nothing downstream cares.
Depth is one, and that is also measured: recursing three levels from
*Category:Fish traps* reaches Alfred Sisley's net paintings and Hawaiian
fisherman watercolours. Immediate subcategories only.
"""
from __future__ import annotations
import argparse
import json
import sys
import time
from pathlib import Path
from typing import Any, Final
import httpx
CATEGORIES_FILE: Final = Path(__file__).with_name("trap_categories.json")
API: Final = "https://commons.wikimedia.org/w/api.php"
# 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 traps generator (https://github.com/kuhyx/signal-bot)"
# The extension is what tells Signal how to render the attachment, so an
# unknown one is dropped rather than guessed. Same set as portraits.SUFFIXES.
SUFFIXES: Final = frozenset({".jpg", ".jpeg", ".png", ".gif"})
# Commons hosts non-free content too. !gekon filters licences at its query and
# endpoints.py calls that filter load-bearing; this is the same idea, on the
# only field Commons exposes for it. Matched case-folded, by prefix.
LICENCES: Final = ("public domain", "cc0", "cc by", "cc-by", "no restrictions")
# imageinfo takes fifty page ids per request, joined with "|". A comma is
# rejected outright ("badinteger"), which is worth writing down because the
# error names the parameter rather than the separator.
BATCH: Final = 50
# A harvest smaller than this is a truncated one, and committing it would
# quietly shrink the command's whole world.
MINIMUM: Final = 500
TOO_MANY_REQUESTS: Final = 429
RETRIES: Final = 6
PAUSE: Final = 0.4
def categories() -> tuple[tuple[str, ...], frozenset[str], tuple[str, ...]]:
"""Return the seeds, the pruned set and the pruned patterns."""
document = json.loads(CATEGORIES_FILE.read_text(encoding="utf-8"))
return (
tuple(document["seeds"]),
frozenset(document["excluded"]),
tuple(document["prune_patterns"]),
)
def _get(params: dict[str, str]) -> dict[str, Any]:
"""One API call, retried with a widening pause when Commons says slow down.
Commons answers 429 after a few hundred requests even at a steady 0.4s,
and a harvest that silently loses a category is worse than a slow one.
"""
query = {"action": "query", "format": "json", "formatversion": "2", **params}
for attempt in range(RETRIES):
time.sleep(PAUSE)
response = httpx.get(API, params=query, headers={"User-Agent": USER_AGENT}, timeout=60.0)
if response.status_code == TOO_MANY_REQUESTS:
time.sleep(2.0**attempt)
continue
response.raise_for_status()
document: dict[str, Any] = response.json()
return document
msg = f"Commons kept answering {TOO_MANY_REQUESTS} for {params}"
raise RuntimeError(msg)
def _members(category: str, kind: str) -> list[dict[str, Any]]:
"""Every member of one category, following continuations."""
found: list[dict[str, Any]] = []
carry_on: str | None = None
while True:
params = {
"list": "categorymembers",
"cmtitle": category,
"cmtype": kind,
"cmlimit": "500",
}
if carry_on is not None:
params["cmcontinue"] = carry_on
document = _get(params)
found += document.get("query", {}).get("categorymembers", [])
carry_on = document.get("continue", {}).get("cmcontinue")
if carry_on is None:
return found
def verify_seeds(seeds: tuple[str, ...]) -> None:
"""Fail before harvesting if any seed category does not exist.
A category that is not there is not an error to Commons -- it is an empty
answer -- so a plausible-looking typo removes a whole subject silently.
Five of the first draft's names were wrong this way.
Raises:
RuntimeError: If any seed is missing, naming all of them at once.
"""
missing: list[str] = []
for start in range(0, len(seeds), BATCH):
document = _get({"titles": "|".join(seeds[start : start + BATCH])})
missing += [
str(page["title"])
for page in document.get("query", {}).get("pages", [])
if page.get("missing")
]
if missing:
msg = f"these seed categories do not exist on Commons: {sorted(missing)}"
raise RuntimeError(msg)
def harvest() -> dict[int, str]:
"""Page id to title, for every usable file one step from the seeds."""
seeds, excluded, patterns = categories()
verify_seeds(seeds)
files: dict[int, str] = {}
seen: set[str] = set(excluded)
for number, seed in enumerate(seeds, start=1):
sys.stderr.write(f"[{number}/{len(seeds)}] {seed} ({len(files)} files so far)\n")
sys.stderr.flush()
branch = [seed, *(sub["title"] for sub in _members(seed, "subcat"))]
for category in branch:
if category in seen or any(pattern in category for pattern in patterns):
continue
seen.add(category)
for entry in _members(category, "file"):
title = str(entry["title"])
if Path(title).suffix.lower() in SUFFIXES:
files[int(entry["pageid"])] = title
return files
def licensed(files: dict[int, str]) -> dict[int, str]:
"""Drop everything whose licence does not permit reposting it."""
kept: dict[int, str] = {}
ids = sorted(files)
sys.stderr.write(f"checking licences on {len(ids)} files\n")
sys.stderr.flush()
for start in range(0, len(ids), BATCH):
chunk = ids[start : start + BATCH]
document = _get(
{
"prop": "imageinfo",
"iiprop": "extmetadata",
"iiextmetadatafilter": "LicenseShortName",
"pageids": "|".join(str(page) for page in chunk),
}
)
for page in document.get("query", {}).get("pages", []):
info = page.get("imageinfo") or [{}]
short = info[0].get("extmetadata", {}).get("LicenseShortName", {}).get("value", "")
name = str(short).casefold()
if name.startswith(LICENCES):
kept[int(page["pageid"])] = files[int(page["pageid"])]
return kept
def _rejection(traps: dict[int, str]) -> str:
"""Return why this harvest must not be written, or an empty string."""
if len(traps) < MINIMUM:
return f"got {len(traps)} traps, expected at least {MINIMUM}"
return ""
def main() -> int:
"""Write traps.json, refusing to write a harvest that failed its own checks."""
parser = argparse.ArgumentParser(description="Regenerate signal_bot/traps.json.")
parser.add_argument(
"--titles",
action="store_true",
help="print every kept title; the committed file is integers, so this is the review",
)
arguments = parser.parse_args()
traps = licensed(harvest())
rejection = _rejection(traps)
if rejection:
sys.stderr.write(f"refusing to write: {rejection}\n")
return 1
if arguments.titles:
for page in sorted(traps):
sys.stdout.write(f"{page}\t{traps[page]}\n")
out = Path(__file__).resolve().parent.parent / "signal_bot" / "traps.json"
document = {"source": str(CATEGORIES_FILE.name), "traps": sorted(traps)}
out.write_text(json.dumps(document, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
sys.stdout.write(f"wrote {len(traps)} traps to {out}\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())