mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:07:19 +01:00
!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.
180 lines
7.1 KiB
Python
Executable File
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)
|