signal-bot/tool/commons_api.py
Krzysztof kuhy Rudnicki 08599e6056 feat: two commands that answer with somebody else's work
!sztuka posts a work of art, skewed 60% Polish; !wiersz posts one short
public-domain Polish poem, whole. Both follow the rule the rest of the bot
already keeps: the repository commits identifiers, and the words arrive over
the wire when somebody asks.

!wiersz was written the other way first, and the gate caught it. A pack of
short public-domain poems was harvested, name-filtered and committed, and
check_personal_data.sh refused it: three lines carried a real group member's
given name, and two still did after a filter that had discarded 31% of the
corpus. That is structural, not a tuning problem -- Polish verse capitalises
every line, so a line-initial capital carries no signal, and that is exactly
where a name sits. The pack now holds Wolne Lektury slugs; the poem, its
title and its author are fetched at post time and appear nowhere in git.
It is also the better version: with no name filter there are no false
positives, so the pool is larger and the attribution is a full name again.

The gate then refused the art category list too, over a painter whose given
name is somebody's. That is the popes.json case exactly, so it takes the
same answer: a file-scoped allowlist entry, still refused everywhere else.

The Commons harvest is now three modules by what they are, not by where the
line count fell: commons_api is the conversation with Commons, commons_harvest
turns categories into a pool of licensed page ids, commons_cache makes an
interrupted run resumable. Along the way it gained licences in the same
request that lists a category (the second pass was 1,500 requests at 2.3s
each), maxlag instead of a guessed sleep, 5xx retries because one 504 used to
destroy a whole run, and Wikidata for works by creators of Polish
citizenship: the Polish pool grew 15,221 -> 20,092.

Batching categories through CirrusSearch looked like a further 6x and was
measured out again: 2,404 files where the authoritative enumeration returns
2,429, a different ~1% missing on every run.

The offline run is 19s where it was four minutes, almost all of it the
deafness gate waiting out three thirty-second liveness probes to prove
something provable in three; the tick is configuration now, and e2e enforces
a 40s budget on itself so that never silently comes back.
2026-08-31 21:32:07 +02:00

180 lines
7.1 KiB
Python
Executable File

#!/usr/bin/env python3
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Talking to Wikimedia Commons, for the generators that harvest from it.
Extracted from ``gen_traps.py`` when ``gen_art.py`` arrived and would
otherwise have carried a second copy of the same retry loop, the same
continuation handling and the same licence filter. Every constant here was
measured by that first generator; the comments say what by.
Nothing in this module knows what a trap or a painting is. It knows how to
ask Commons a question without being rate-limited into silence, and how to
tell whether a file may be reposted.
"""
from __future__ import annotations
import time
from typing import Any, Final
import httpx
API: Final = "https://commons.wikimedia.org/w/api.php"
# 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
# Wikimedia's etiquette asks for requests in series, and a strictly serial
# walk of a hundred-thousand-file harvest is the best part of an hour. Three
# is the compromise: enough to hide the round trip, few enough to stay a
# polite read against a project that serves everybody. The 429 handling in
# get() is the safety net rather than the plan -- if Commons does push back,
# every worker backs off exponentially and the harvest slows instead of
# failing. Only the per-category *file* queries run this way; claiming a
# category still happens serially, so `seen` and the pool ordering it
# guarantees are untouched.
WORKERS: Final = 3
TOO_MANY_REQUESTS: Final = 429
SERVER_ERROR: Final = 500
RETRIES: Final = 6
PAUSE: Final = 0.4
# Wikimedia's documented way to be a good citizen, and better than guessing a
# sleep: the server reports its own replication lag and refuses work when it
# is behind, so the throttle is feedback instead of superstition. Five seconds
# is the value their API docs give for interactive tools.
MAXLAG: Final = "5"
def get(params: dict[str, str], user_agent: 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.
Args:
params: The query, minus the action/format boilerplate.
user_agent: Wikimedia's policy requires a descriptive agent with a
contact URL; the generic client default is rate-limited hard and
may be blocked outright.
Returns:
The decoded response body.
Raises:
RuntimeError: If Commons answered 429 to every attempt.
"""
query = {
"action": "query",
"format": "json",
"formatversion": "2",
"maxlag": MAXLAG,
**params,
}
for attempt in range(RETRIES):
time.sleep(PAUSE)
try:
response = httpx.get(
API, params=query, headers={"User-Agent": user_agent}, timeout=120.0
)
except httpx.TransportError:
# A dropped connection is not an answer; treat it like a 5xx.
time.sleep(2.0**attempt)
continue
# 429 is Commons asking for less; 5xx is Commons failing under the
# weight of a generator+imageinfo query, which is expensive enough to
# time out on the biggest categories. Both are transient and both
# used to be fatal -- a single 504 on Category:Drawings threw away a
# nine-minute harvest, which is what put this branch here.
if response.status_code == TOO_MANY_REQUESTS or response.status_code >= SERVER_ERROR:
time.sleep(2.0**attempt)
continue
response.raise_for_status()
document: dict[str, Any] = response.json()
# maxlag is reported in-band with HTTP 200, not as a status code.
if isinstance(document.get("error"), dict) and document["error"].get("code") == "maxlag":
time.sleep(2.0**attempt)
continue
return document
msg = f"Commons kept refusing {params}"
raise RuntimeError(msg)
def members(category: str, kind: str, user_agent: 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, user_agent)
found += document.get("query", {}).get("categorymembers", [])
carry_on = document.get("continue", {}).get("cmcontinue")
if carry_on is None:
return found
def licence_of(page: dict[str, Any]) -> str:
"""The licence short name for one imageinfo page, or "" if it has none.
Every field is checked rather than indexed, because MediaWiki serialises
an **empty map as an empty list** -- a PHP array quirk that
formatversion=2 does not fully hide. A file whose extmetadata is empty
arrives as ``"extmetadata": []``, and calling .get() on that threw
AttributeError forty thousand files into a harvest and lost the run. The
traps corpus happened not to contain one; the art corpus does.
"""
info = page.get("imageinfo")
if not isinstance(info, list) or not info or not isinstance(info[0], dict):
return ""
meta = info[0].get("extmetadata")
if not isinstance(meta, dict):
return ""
short = meta.get("LicenseShortName")
if not isinstance(short, dict):
return ""
return str(short.get("value", ""))
def verify_seeds(seeds: tuple[str, ...], user_agent: 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 trap generator's first-draft names were wrong this way, and
fourteen of the art generator's were.
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])}, user_agent)
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)