mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 18:07:29 +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.
223 lines
8.8 KiB
Python
Executable File
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
|