signal-bot/tool/commons_harvest.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

223 lines
8.8 KiB
Python
Executable File

#!/usr/bin/env python3
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Harvesting files out of Commons categories, licence and all.
Split from ``commons_api`` at the 250-line cap. That module is the transport
-- one request, retried politely -- and this one is everything built on it:
which categories a pool draws from, which files may be reposted, and the
cache that lets an interrupted harvest resume instead of starting over.
"""
from __future__ import annotations
import sys
from concurrent.futures import ThreadPoolExecutor
from functools import partial
from pathlib import Path
from typing import Any
from commons_api import (
BATCH,
LICENCES,
SUFFIXES,
WORKERS,
get,
licence_of,
members,
)
def licensed_members(category: str, *, user_agent: str) -> dict[int, str]:
"""Every usable, repostable file in one category, in a single pass.
``list=categorymembers`` names the files and ``prop=imageinfo`` reports
the licence, and MediaWiki will do both in one request if the first is
run as a *generator* for the second. Doing them separately -- which is
what this module did first, and what gen_traps still does -- costs an
extra request per fifty files, and those requests are the slow kind:
measured at 2.3s each against 0.75s for a plain listing, because
extmetadata is expensive. On a 75,000-file harvest that second pass was
forty-six minutes on its own.
Verified equivalent to ``members`` + ``licensed`` page id for page id, on
a painter's category of 387 files and on Category:Ukiyo-e of 311.
"""
kept: dict[int, str] = {}
carry: dict[str, str] = {}
while True:
document = get(
{
"generator": "categorymembers",
"gcmtitle": category,
"gcmtype": "file",
"gcmlimit": "500",
"prop": "imageinfo",
"iiprop": "extmetadata",
"iiextmetadatafilter": "LicenseShortName",
**carry,
},
user_agent,
)
for page in document.get("query", {}).get("pages", []):
title = str(page["title"])
if Path(title).suffix.lower() not in SUFFIXES:
continue
if licence_of(page).casefold().startswith(LICENCES):
kept[int(page["pageid"])] = title
carry = document.get("continue", {})
if not carry:
return kept
def _fetch_licensed(
wanted: list[str],
user_agent: str,
cache: dict[str, dict[int, str]] | None,
) -> dict[int, str]:
"""Every licensed file in these categories, reusing whatever is cached.
Split out of walk because it is the only part that is concurrent and the
only part that touches the cache; walk stays a description of which
categories get visited, which is what makes the pool ordering readable.
"""
files: dict[int, str] = {}
if cache is not None:
for category in [name for name in wanted if name in cache]:
files.update(cache[category])
wanted = [name for name in wanted if name not in cache]
# A quotation mark would end the incategory: term early and silently
# change the query, so those few categories keep the per-category path.
# Per category, via list=categorymembers, because that is the
# authoritative enumeration. Asking CirrusSearch instead (incategory:
# ORed across a batch) looked like a 6x win and was measured to be
# neither: it returned 2404 files where this returns 2429, dropped a
# different ~1% on every run, and was slower once the overflow path
# started firing. A search index is ranked and eventually consistent;
# deep pagination over one silently loses rows. Kept as a comment so the
# next person does not spend an afternoon rediscovering it.
with ThreadPoolExecutor(max_workers=WORKERS) as pool:
found_each = pool.map(partial(licensed_members, user_agent=user_agent), wanted)
for category, found in zip(wanted, found_each, strict=True):
files.update(found)
if cache is not None:
cache[category] = found
return files
def walk(
seeds: tuple[str, ...],
excluded: frozenset[str],
patterns: tuple[str, ...],
user_agent: str,
seen: set[str] | None = None,
*,
with_licences: bool = False,
cache: dict[str, dict[int, str]] | None = None,
) -> dict[int, str]:
"""Page id to title, for every usable file one step from the seeds.
Depth is one, and that is measured rather than chosen: recursing three
levels from *Category:Fish traps* reaches Alfred Sisley's net paintings
and Hawaiian fisherman watercolours. Immediate subcategories only.
Args:
seeds: Categories to start from.
excluded: Categories pruned as whole subtrees.
patterns: A subcategory whose title contains one of these is pruned.
user_agent: Passed through to every request.
seen: Categories already walked. Pass one set across two calls to keep
two pools disjoint -- a file in both would be drawn at both odds.
with_licences: Filter licences in the same request that lists the
files, rather than in a second pass. Faster by far -- see
licensed_members -- and the caller then must not call
``licensed`` again.
cache: Category to its files, from load_cache. Anything already in
here is not fetched again, which is what makes an interrupted
harvest resumable instead of wasted. Mutated as the walk goes.
Returns:
Every file found, page id to title.
"""
files: dict[int, str] = {}
visited = set(excluded) if seen is None else seen | 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", user_agent))]
wanted: list[str] = []
for category in branch:
if category in visited or any(pattern in category for pattern in patterns):
continue
visited.add(category)
wanted.append(category)
if with_licences:
files.update(_fetch_licensed(wanted, user_agent, cache))
continue
for category in wanted:
for entry in members(category, "file", user_agent):
title = str(entry["title"])
if Path(title).suffix.lower() in SUFFIXES:
files[int(entry["pageid"])] = title
if seen is not None:
seen |= visited
return files
def _titles_page(titles: list[str], user_agent: str) -> dict[str, Any]:
"""One imageinfo lookup for a batch of file titles."""
return get(
{
"titles": "|".join(titles),
"prop": "imageinfo",
"iiprop": "extmetadata",
"iiextmetadatafilter": "LicenseShortName",
},
user_agent,
)
def licensed_titles(titles: list[str], user_agent: str) -> dict[int, str]:
"""Resolve Commons file titles to page ids, keeping only repostable ones.
The mirror image of ``licensed``: that one starts from page ids the walk
found, this one starts from titles somebody else named -- Wikidata, which
knows the filename and not the id. Same licence rule, same batch size.
"""
kept: dict[int, str] = {}
batches = [titles[start : start + BATCH] for start in range(0, len(titles), BATCH)]
with ThreadPoolExecutor(max_workers=WORKERS) as pool:
documents = list(pool.map(partial(_titles_page, user_agent=user_agent), batches))
for document in documents:
for page in document.get("query", {}).get("pages", []):
if page.get("missing"):
continue
title = str(page["title"])
if Path(title).suffix.lower() not in SUFFIXES:
continue
if licence_of(page).casefold().startswith(LICENCES):
kept[int(page["pageid"])] = title
return kept
def licensed(files: dict[int, str], user_agent: 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),
},
user_agent,
)
for page in document.get("query", {}).get("pages", []):
if licence_of(page).casefold().startswith(LICENCES):
kept[int(page["pageid"])] = files[int(page["pageid"])]
return kept