From 14d422295f71dc4aeaec27cd5b45485cd96400b4 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 31 Aug 2026 18:01:27 +0200 Subject: [PATCH] feat: a word with two meanings gets the one made of steel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit !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. - " 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 Claude-Session: https://claude.ai/code/session_01416TegJ3sa8xL5qGGgrygP --- DOCS-commands.md | 1 + README.md | 4 +- fake_signal/scenarios/trap_command.yaml | 5 + signal_bot/aliases.json | 28 + signal_bot/bot.py | 2 + signal_bot/commons.py | 126 ++ signal_bot/endpoints.py | 11 +- signal_bot/help_command.py | 2 +- signal_bot/http_client.py | 31 +- signal_bot/pack.py | 2 +- signal_bot/portraits.py | 26 +- signal_bot/traps.json | 2223 +++++++++++++++++++++++ signal_bot/traps_command.py | 116 ++ tests/test_commons.py | 123 ++ tests/test_help_command.py | 2 + tests/test_traps_command.py | 191 ++ tool/gen_traps.py | 216 +++ tool/trap_categories.json | 58 + 18 files changed, 3130 insertions(+), 37 deletions(-) create mode 100644 fake_signal/scenarios/trap_command.yaml create mode 100644 signal_bot/commons.py create mode 100644 signal_bot/traps.json create mode 100644 signal_bot/traps_command.py create mode 100644 tests/test_commons.py create mode 100644 tests/test_traps_command.py create mode 100755 tool/gen_traps.py create mode 100644 tool/trap_categories.json diff --git a/DOCS-commands.md b/DOCS-commands.md index 9c7228b..3492379 100644 --- a/DOCS-commands.md +++ b/DOCS-commands.md @@ -15,6 +15,7 @@ This file is the trigger reference — every spelling that resolves, which | cat | `!kot` `!koty` `!kots` `!cat` `!cats` `!meow` `!miau` + styled variants | | dog | `!pies` `!psy` `!dog` `!dogs` `!woof` `!szczek` + styled variants | | gecko | `!gekon` `!gekony` `!geko` `!gecko` `!geckon` `!geckos` | +| trap | `!pułapka` `!pulapka` `!pułapki` `!pulapki` `!pułapkę` `!pulapke` `!trap` `!traps` `!trapy` `!trapper` `!sidła` `!sidla` `!sidło` `!sidlo` `!wnyk` `!wnyki` `!potrzask` `!zasadzka` `!klatka` `!samołówka` `!samolowka` `!beartrap` `!bear_trap` `!snare` `!snares` `!mousetrap` | | pope | `!papież` `!papiez` `!papieze` `!papieża` `!papa` `!pope` `!popes` `!pontifex` + styled variants | | stats_today | `!staty` `!statystyki` `!stats` `!dziś` `!dzis` | | stats_yesterday | `!wczoraj` `!yesterday` | diff --git a/README.md b/README.md index 3a5ad86..5cc58c6 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # signal-bot -A Signal group bot: `!cat` / `!dog` / `!gekon` image commands, `!papież`, and -group message statistics. The group's own data stays out: `DOCS-privacy.md`. +A Signal group bot: `!cat` / `!dog` / `!gekon` / `!pułapka` image commands, +`!papież`, and group statistics. The group's own data stays out: `DOCS-privacy.md`. Runs on its **own registered Signal number** (or, if you prefer, as a linked device on an existing account) via diff --git a/fake_signal/scenarios/trap_command.yaml b/fake_signal/scenarios/trap_command.yaml new file mode 100644 index 0000000..78ec404 --- /dev/null +++ b/fake_signal/scenarios/trap_command.yaml @@ -0,0 +1,5 @@ +name: "!pułapka posts a photograph of the device, captioned with what it is" +inject: + - { text: "!pułapka", uuid: uuid-trap, name: Trapper } +expect: + - { kind: image, to: reply } diff --git a/signal_bot/aliases.json b/signal_bot/aliases.json index f5e3dda..690171d 100644 --- a/signal_bot/aliases.json +++ b/signal_bot/aliases.json @@ -30,6 +30,34 @@ "!geckon", "!geckos" ], + "trap": [ + "!pułapka", + "!pulapka", + "!pułapki", + "!pulapki", + "!pułapkę", + "!pulapke", + "!trap", + "!traps", + "!trapy", + "!trapper", + "!sidła", + "!sidla", + "!sidło", + "!sidlo", + "!wnyk", + "!wnyki", + "!potrzask", + "!zasadzka", + "!klatka", + "!samołówka", + "!samolowka", + "!beartrap", + "!bear_trap", + "!snare", + "!snares", + "!mousetrap" + ], "pope": [ "!papież", "!papiez", diff --git a/signal_bot/bot.py b/signal_bot/bot.py index 3a9096f..ac4f700 100644 --- a/signal_bot/bot.py +++ b/signal_bot/bot.py @@ -35,6 +35,7 @@ from signal_bot.stats import periods from signal_bot.stats.commands import register_stats_commands from signal_bot.stats.nightly import days_to_report, mark_reported from signal_bot.suggest_command import register_suggest_command +from signal_bot.traps_command import register_trap_command from signal_bot.versioning import POPE if TYPE_CHECKING: @@ -198,6 +199,7 @@ def build_bot( endpoints=config.image_api_urls, ) register_pope_command(registry, portraits, decorate=rate_limit) + register_trap_command(registry, fetcher, config.commons_api_url, decorate=rate_limit) register_stats_commands(registry, decorate=rate_limit) # Rate-limited like every other command, and unlike !add_photo, whose # "NO CAP, NO LIMITS" is a deliberate exception rather than a default. diff --git a/signal_bot/commons.py b/signal_bot/commons.py new file mode 100644 index 0000000..951952f --- /dev/null +++ b/signal_bot/commons.py @@ -0,0 +1,126 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""What a Wikimedia Commons image fetch needs, shared by its two callers. + +`!papież` names a file by title and caches the result; `!pułapka` names one by +page id and does not cache anything. Everything between those two ends -- the +query, the width, the place `thumburl` turns up in the answer -- is identical, +so it lives here rather than twice. +""" + +from __future__ import annotations + +import html +import re +from typing import Any, Final + +from signal_bot.http_client import ImageFetchError + +# Where imageinfo puts the thumbnail URL in its reply. The same path whether +# the file was named by title or by page id. +THUMB_PATH: Final = ("query", "pages", 0, "imageinfo", 0, "thumburl") +# Commons hosts these four for the 267 portraits: 245 jpg, 15 png, 6 gif, +# 1 jpeg. An unknown suffix is refused rather than guessed, because the +# extension is what tells Signal how to render the attachment. +SUFFIXES: Final = frozenset({".jpg", ".jpeg", ".png", ".gif"}) + + +# Long enough to say what the thing is, short enough not to bury the photo. +CAPTION_LIMIT: Final = 220 +_TAGS: Final = re.compile(r"<[^>]+>") +# The Fisheries records put their prose first and then an HTML list of +# catalogue fields ("Subject: Fishing nets", "Tag: Fisheries Techniques"). +# Cutting at the list keeps the sentence and drops the filing cabinet. +_LIST: Final = " dict[str, str]: + """Build the imageinfo query for one Commons file. + + Args: + width: Thumbnail width to ask for, in pixels. + title: Full page title, "File:" prefix included. + page_id: Commons page id. Preferred where the caller stores a list, + because an integer cannot carry somebody's name into the + repository the way a filename can. + describe: Also ask for the file's description, so one request + answers both what the picture is and where it lives. + + Returns: + Query parameters, ready for ImageFetcher.fetch_bytes. + + Raises: + ValueError: If both or neither of title and page_id were given. + Silently preferring one would make a caller's typo look like a + missing file, which is the same shape as a real 404. + """ + if (title is None) == (page_id is None): + msg = "name a Commons file by exactly one of title or page_id" + raise ValueError(msg) + selector = {"titles": title} if title is not None else {"pageids": str(page_id)} + described = ( + {"iiprop": "url|extmetadata", "iiextmetadatafilter": "ImageDescription"} + if describe + else {"iiprop": "url"} + ) + return { + "action": "query", + "format": "json", + "formatversion": "2", + "prop": "imageinfo", + "iiurlwidth": str(width), + **described, + **selector, + } + + +def page_of(document: object) -> dict[str, Any]: + """Return the single page an imageinfo answer describes. + + Raises: + ImageFetchError: If Commons answered without one -- a deleted or + renumbered page id comes back as `{"missing": true}` and has no + imageinfo at all. + """ + pages = document.get("query", {}).get("pages", []) if isinstance(document, dict) else [] + if not pages or not pages[0].get("imageinfo"): + msg = f"Commons named no image: {document!r:.200}" + raise ImageFetchError(msg) + page: dict[str, Any] = pages[0] + return page + + +def caption_for(page: dict[str, Any]) -> str: + """Return the line that rides along with the photograph. + + Commons descriptions are HTML, sometimes several languages deep, and + sometimes only the filename repeated back. So the tags come out, the + catalogue list is cut, and a description that says nothing falls back to + the title -- but the file's own page is always linked, because that is + what answers "what am I even looking at" when the rest does not. + + The text is fetched at run time and posted to the group; none of it is + ever written into this repository, which is why it may be free text + where traps.json may not. + """ + info = page["imageinfo"][0] + raw = str(info.get("extmetadata", {}).get("ImageDescription", {}).get("value", "")) + text = " ".join(html.unescape(_TAGS.sub(" ", raw.split(_LIST, 1)[0])).split()) + if text.lower().startswith(_BOILERPLATE): + text = "" + if not text: + text = str(page.get("title", "")).removeprefix("File:").rsplit(".", 1)[0] + if len(text) > CAPTION_LIMIT: + text = text[:CAPTION_LIMIT].rstrip() + "…" + url = str(info.get("descriptionurl", "")) + return f"{text}\n{url}" if url else text diff --git a/signal_bot/endpoints.py b/signal_bot/endpoints.py index 661995a..6338d3d 100644 --- a/signal_bot/endpoints.py +++ b/signal_bot/endpoints.py @@ -22,11 +22,12 @@ DEFAULT_GECKO_API_URL: Final = ( "?taxon_id=371057&photos=true&quality_grade=research" "&photo_license=cc-by,cc-by-sa,cc0&per_page=1" ) -# Commons resolves a portrait *filename* to a real upload.wikimedia.org URL. -# Special:FilePath would be shorter but answers 302, and this repo's httpx -# client does not follow redirects; imageinfo hands back the final URL and a -# thumbnail in one hop, which is also what keeps a 4000px original off Signal. +# Commons resolves a file -- named by title for !papiez, by page id for +# !pulapka -- to a real upload.wikimedia.org URL. Special:FilePath would be +# shorter but answers 302, and this repo's httpx client does not follow +# redirects; imageinfo hands back the final URL and a thumbnail in one hop, +# which is also what keeps a 4000px original off Signal. DEFAULT_COMMONS_API_URL: Final = "https://commons.wikimedia.org/w/api.php" # Wide enough to look like a photograph rather than an avatar, small enough # that the whole 267-portrait cache stays well under 100 MB. -PORTRAIT_WIDTH: Final = 640 +THUMB_WIDTH: Final = 640 diff --git a/signal_bot/help_command.py b/signal_bot/help_command.py index ba26563..538323c 100644 --- a/signal_bot/help_command.py +++ b/signal_bot/help_command.py @@ -38,7 +38,7 @@ HEADING: Final = "Komendy" # Anything missing from here still gets printed, in a block of its own -- see # the module docstring. GROUPS: Final[tuple[tuple[str, tuple[str, ...]], ...]] = ( - ("Obrazki", ("cat", "dog", "gecko")), + ("Obrazki", ("cat", "dog", "gecko", "trap")), ("Papież", ("pope",)), ("Propozycje", ("suggest",)), ( diff --git a/signal_bot/http_client.py b/signal_bot/http_client.py index 96dc697..b7ce4be 100644 --- a/signal_bot/http_client.py +++ b/signal_bot/http_client.py @@ -65,17 +65,32 @@ class ImageFetcher: Raises: ImageFetchError: The JSON did not contain a usable URL. """ - response = await self._client.get(api_url, params=params) - response.raise_for_status() - image_url = _walk(response.json(), url_path) + image_url = _walk(await self.fetch_json(api_url, params), url_path) if rewrite is not None: image_url = image_url.replace(*rewrite) + return await self.fetch_image(image_url) - # These bytes are never written to a path built from the remote URL: - # the original bot named a local file after the URL's last path - # segment and wrote it into the process CWD, which is both path - # traversal and unbounded. portraits.py does cache to disk, but under - # a name it derives from the pope's ordinal, never from upstream. + async def fetch_json(self, api_url: str, params: dict[str, str] | None = None) -> object: + """Fetch one JSON document from an API. + + Separate from fetch_bytes because a Commons answer carries more than + the picture: !pułapka needs the description in the same reply, and + asking twice would be both wasteful and a chance for the two halves + to disagree. + """ + response = await self._client.get(api_url, params=params) + response.raise_for_status() + return response.json() + + async def fetch_image(self, image_url: str) -> bytes: + """Fetch the bytes at an already-resolved image URL. + + These bytes are never written to a path built from the remote URL: + the original bot named a local file after the URL's last path + segment and wrote it into the process CWD, which is both path + traversal and unbounded. portraits.py does cache to disk, but under + a name it derives from the pope's ordinal, never from upstream. + """ image = await self._client.get(image_url) image.raise_for_status() return image.content diff --git a/signal_bot/pack.py b/signal_bot/pack.py index 75cee02..d7683f5 100644 --- a/signal_bot/pack.py +++ b/signal_bot/pack.py @@ -42,7 +42,7 @@ MEDIA_DIR: Final = "media" # What Signal is told an attachment is. Closed, and checked at load time: the # suffix is the only thing that tells Signal how to render the file, so an -# unknown one is refused rather than guessed. Mirrors portraits._SUFFIXES. +# unknown one is refused rather than guessed. Mirrors commons.SUFFIXES. MEDIA_TYPES: Final[dict[str, str]] = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", diff --git a/signal_bot/portraits.py b/signal_bot/portraits.py index 74152f4..497c9c6 100644 --- a/signal_bot/portraits.py +++ b/signal_bot/portraits.py @@ -12,22 +12,16 @@ from __future__ import annotations import base64 import os from pathlib import Path -from typing import TYPE_CHECKING, Final +from typing import TYPE_CHECKING -from signal_bot.endpoints import PORTRAIT_WIDTH +from signal_bot.commons import SUFFIXES, THUMB_PATH, imageinfo_params +from signal_bot.endpoints import THUMB_WIDTH from signal_bot.http_client import ImageFetchError if TYPE_CHECKING: from signal_bot.http_client import ImageFetcher from signal_bot.versioning import Pope -# Where imageinfo puts the thumbnail URL in its reply. -_THUMB_PATH: Final = ("query", "pages", 0, "imageinfo", 0, "thumburl") -# Commons hosts these four for the 267 portraits: 245 jpg, 15 png, 6 gif, -# 1 jpeg. An unknown suffix is refused rather than guessed, because the -# extension is what tells Signal how to render the attachment. -_SUFFIXES: Final = frozenset({".jpg", ".jpeg", ".png", ".gif"}) - def default_cache_dir() -> Path: """Return the portrait directory, preferring the one systemd hands us. @@ -68,7 +62,7 @@ class PortraitCache: data in a way an integer is not. """ suffix = Path(pope.portrait).suffix.lower() - if suffix not in _SUFFIXES: + if suffix not in SUFFIXES: msg = f"portrait for #{pope.ordinal} has an unusable suffix: {pope.portrait!r}" raise ImageFetchError(msg) return self._dir / f"{pope.ordinal:03d}{suffix}" @@ -86,16 +80,8 @@ class PortraitCache: image = await self._fetcher.fetch_bytes( self._api_url, - _THUMB_PATH, - params={ - "action": "query", - "format": "json", - "formatversion": "2", - "prop": "imageinfo", - "iiprop": "url", - "iiurlwidth": str(PORTRAIT_WIDTH), - "titles": f"File:{pope.portrait}", - }, + THUMB_PATH, + params=imageinfo_params(THUMB_WIDTH, title=f"File:{pope.portrait}"), ) _write(path, image) return image diff --git a/signal_bot/traps.json b/signal_bot/traps.json new file mode 100644 index 0000000..5031189 --- /dev/null +++ b/signal_bot/traps.json @@ -0,0 +1,2223 @@ +{ + "source": "trap_categories.json", + "traps": [ + 83848, + 116723, + 226912, + 252209, + 329601, + 352710, + 396271, + 423041, + 474690, + 507446, + 538108, + 608011, + 711270, + 729362, + 745433, + 807979, + 814712, + 817931, + 817933, + 874459, + 896042, + 919331, + 955080, + 1196327, + 1267658, + 1497320, + 1506776, + 1551795, + 1570846, + 1575286, + 1618733, + 1718515, + 1731893, + 1731898, + 1740653, + 1827193, + 1868856, + 1871414, + 1925361, + 2008565, + 2008742, + 2014184, + 2015815, + 2187575, + 2336022, + 2416662, + 2439656, + 2439659, + 2470469, + 2513736, + 2534375, + 2581838, + 2648930, + 2690523, + 2773988, + 2785269, + 2818248, + 2842254, + 2871278, + 2871288, + 2897838, + 2898112, + 2995088, + 3015819, + 3103711, + 3148163, + 3194546, + 3212171, + 3282973, + 3358979, + 3421436, + 3445663, + 3464944, + 3503900, + 3553741, + 3660897, + 3745072, + 3745751, + 3801791, + 3804500, + 3821276, + 3854165, + 3899580, + 3940840, + 3983559, + 4116873, + 4153204, + 4196258, + 4220407, + 4235488, + 4245136, + 4337255, + 4337427, + 4403743, + 4403766, + 4505449, + 4533438, + 4539569, + 4558220, + 4565550, + 4565565, + 4565582, + 4565606, + 4621782, + 4647210, + 4647219, + 4711547, + 4767144, + 4807888, + 4862948, + 4905588, + 4976885, + 5342704, + 5368471, + 5392271, + 5588993, + 5616840, + 5787303, + 5802721, + 5803374, + 5874037, + 5990608, + 5990616, + 6142139, + 6151681, + 6172937, + 6172944, + 6173148, + 6192923, + 6192927, + 6192929, + 6192947, + 6192950, + 6192956, + 6192960, + 6192970, + 6195163, + 6195166, + 6481979, + 6482286, + 6601023, + 6637805, + 6748007, + 6755974, + 6777953, + 6808879, + 6808881, + 6808882, + 6808888, + 6808906, + 6852244, + 6944504, + 6977858, + 6980338, + 6980356, + 6980373, + 6980390, + 6980411, + 6980440, + 6980463, + 6984287, + 7007680, + 7035987, + 7076966, + 7112118, + 7112139, + 7116361, + 7122669, + 7193959, + 7232334, + 7385748, + 7437455, + 7470425, + 7609654, + 7631929, + 7631930, + 7631931, + 7631932, + 7631933, + 7642216, + 7642217, + 7642218, + 7751353, + 7768333, + 7828605, + 7828650, + 7837527, + 7837538, + 7837544, + 7885067, + 7939442, + 7939459, + 7939491, + 7944763, + 7944766, + 7944772, + 7944773, + 7944777, + 7967342, + 7974891, + 8027386, + 8027441, + 8039259, + 8039262, + 8039264, + 8089267, + 8089338, + 8215246, + 8215252, + 8321581, + 8361232, + 8525028, + 8537730, + 8537745, + 8538226, + 8538251, + 8538261, + 8538271, + 8538284, + 8538295, + 8538519, + 8538586, + 8552607, + 8577483, + 8577577, + 8582102, + 8582598, + 8590507, + 8591637, + 8596619, + 8596626, + 8599712, + 8600003, + 8600572, + 8600579, + 8601147, + 8601272, + 8601716, + 8602145, + 8603981, + 8605985, + 8607422, + 8608410, + 8609595, + 8610106, + 8610725, + 8611892, + 8612890, + 8613944, + 8653451, + 8669740, + 8678831, + 8753116, + 9109973, + 9109977, + 9114806, + 9123147, + 9151286, + 9203726, + 9215724, + 9229133, + 9236013, + 9237562, + 9280393, + 9282056, + 9285806, + 9318083, + 9325371, + 9345756, + 9354244, + 9375751, + 9376077, + 9379688, + 9455667, + 9484809, + 9553339, + 9562970, + 9580150, + 9644450, + 9669827, + 9708330, + 9757337, + 9761770, + 9761778, + 9761786, + 9841769, + 9841771, + 9845778, + 9846873, + 9873849, + 9979602, + 10087702, + 10144448, + 10190368, + 10190369, + 10190370, + 10229755, + 10236953, + 10241928, + 10263144, + 10298326, + 10384553, + 10405564, + 10417526, + 10417573, + 10432768, + 10440318, + 10500359, + 10668155, + 10707672, + 10744910, + 10895768, + 10947819, + 11006469, + 11006470, + 11020565, + 11020688, + 11020743, + 11020744, + 11026009, + 11026028, + 11030786, + 11032508, + 11032509, + 11032510, + 11050800, + 11050811, + 11189597, + 11189934, + 11189963, + 11189982, + 11190012, + 11207022, + 11230552, + 11323872, + 11380552, + 11385335, + 11430037, + 11444257, + 11444262, + 11463237, + 11465937, + 11466408, + 11517778, + 11522121, + 11548900, + 11590451, + 11771460, + 11828905, + 11843338, + 11844541, + 11871865, + 11872009, + 11872023, + 11872033, + 11872035, + 11872039, + 12037483, + 12042019, + 12121894, + 12125094, + 12167994, + 12281530, + 12285346, + 12299674, + 12469639, + 12495354, + 12507351, + 12522132, + 12522140, + 12522142, + 12522143, + 12522147, + 12522151, + 12523894, + 12545171, + 12585255, + 12602396, + 12805727, + 12811192, + 12811263, + 12860267, + 12882350, + 12891192, + 12903871, + 12949996, + 12987771, + 12996281, + 13035828, + 13055803, + 13103128, + 13105431, + 13118148, + 13202872, + 13210630, + 13365821, + 13367751, + 13378563, + 13379127, + 13379905, + 13381508, + 13386172, + 13394784, + 13394785, + 13421830, + 13434464, + 13437458, + 13443948, + 13462351, + 13471081, + 13474495, + 13474496, + 13489589, + 13491474, + 13510230, + 13526430, + 13526551, + 13526684, + 13555406, + 13643322, + 13724968, + 13755368, + 13764280, + 13766359, + 13789083, + 13789084, + 13789085, + 13799380, + 13857250, + 13913076, + 14041150, + 14053940, + 14213079, + 14218627, + 14239589, + 14246127, + 14446999, + 14561259, + 14571236, + 14573797, + 14834545, + 14999351, + 15007822, + 15025113, + 15244841, + 15244849, + 15253644, + 15271413, + 15658099, + 15915235, + 15915280, + 15915305, + 15915339, + 15915402, + 16028892, + 16192190, + 16192192, + 16358433, + 16387672, + 16388732, + 16402606, + 16402607, + 16402608, + 16402609, + 16463607, + 16735933, + 16893166, + 16911773, + 16932573, + 16939785, + 16960563, + 16960564, + 16960571, + 16987263, + 17087093, + 17087465, + 17087573, + 17101848, + 17112808, + 17131844, + 17150133, + 17219610, + 17346876, + 17376742, + 17393586, + 17393587, + 17424862, + 17430904, + 17463392, + 17505144, + 17584705, + 17686254, + 17700421, + 17700609, + 17700905, + 17703820, + 17718453, + 17724894, + 17724898, + 17801639, + 17830410, + 17916262, + 17916414, + 17969562, + 17978061, + 17978240, + 18015572, + 18015574, + 18096071, + 18190162, + 18195441, + 18195442, + 18195443, + 18195444, + 18195445, + 18195446, + 18384841, + 18526753, + 18589687, + 18620820, + 18731693, + 18731696, + 18731703, + 18731707, + 18779482, + 18782812, + 18798420, + 18894613, + 19318396, + 19318397, + 19402466, + 19564889, + 19564921, + 19688856, + 19856894, + 20054319, + 20305884, + 20351078, + 20356984, + 20359313, + 20359315, + 20359887, + 20392212, + 20392222, + 20392227, + 20768233, + 20906122, + 20906169, + 20994796, + 21040949, + 21040950, + 21040951, + 21040954, + 21262123, + 21392595, + 21447177, + 21571186, + 21600095, + 21600098, + 21600193, + 21687139, + 21727804, + 21902101, + 21931846, + 21931863, + 21932037, + 22131014, + 22142126, + 22474787, + 22599489, + 22606962, + 22606963, + 22654076, + 22655523, + 22724755, + 22734914, + 22737190, + 22819706, + 22819713, + 22819717, + 22819718, + 22819720, + 22848101, + 22848304, + 22909780, + 22909781, + 22946575, + 22955086, + 22969196, + 22969197, + 23320750, + 23320752, + 23341440, + 23486612, + 23695737, + 24018361, + 24177374, + 24252059, + 24358212, + 24359610, + 24377484, + 24436015, + 24436313, + 24490151, + 24612147, + 24642917, + 24642927, + 24646351, + 24646387, + 24667528, + 24788008, + 24870887, + 24870895, + 24885052, + 24885223, + 24885398, + 24913053, + 24913054, + 24917859, + 24924661, + 24924974, + 24925964, + 24926215, + 24936235, + 24939469, + 24971013, + 25381845, + 25398041, + 25513345, + 25514413, + 25524367, + 25740425, + 25740426, + 25805848, + 25823538, + 25823816, + 25824231, + 25835597, + 25923093, + 25992535, + 26012208, + 26012243, + 26063737, + 26195294, + 26223925, + 26244104, + 26259304, + 26283019, + 26302255, + 26306277, + 26308147, + 26325396, + 26325544, + 26330480, + 26375761, + 26385293, + 26667999, + 27013207, + 27033672, + 27133531, + 27201244, + 27201255, + 27236650, + 27374590, + 27420683, + 27501563, + 27504959, + 27597026, + 27673670, + 27703459, + 27733597, + 27749963, + 27749964, + 27750025, + 27812821, + 27833650, + 28064007, + 28064199, + 28069026, + 28069027, + 28069619, + 28337964, + 28438129, + 28767887, + 28908240, + 28923904, + 28923905, + 28923908, + 28973513, + 28979098, + 29147424, + 29160640, + 29235886, + 29362332, + 29450891, + 29450902, + 29461098, + 29578269, + 29616928, + 29684133, + 29692784, + 29692846, + 29738347, + 29751149, + 29790616, + 29812314, + 29812315, + 29812319, + 29812320, + 29812328, + 29812353, + 29813722, + 29813725, + 29813741, + 29813745, + 29826405, + 29826406, + 29826434, + 29830387, + 29830450, + 29934664, + 30038940, + 30079801, + 30198483, + 30441723, + 30473813, + 30886681, + 30900551, + 31071541, + 31174192, + 31176025, + 31314984, + 31314991, + 31315155, + 31315156, + 31315162, + 31315165, + 31315558, + 31316523, + 31316575, + 31316576, + 31316578, + 31398704, + 31428791, + 31428792, + 31472404, + 31484438, + 31500717, + 31525099, + 31525321, + 31525322, + 31525583, + 31525584, + 31525585, + 31529440, + 31531742, + 31551869, + 31755842, + 31865626, + 31993212, + 32080591, + 32081254, + 32361898, + 32361988, + 32368236, + 32548191, + 32823284, + 32966153, + 33347801, + 33400128, + 33430804, + 33435063, + 33450779, + 33458547, + 33655501, + 33655503, + 33720685, + 33755661, + 33932456, + 33949757, + 33986691, + 33986747, + 34136829, + 34171883, + 34219545, + 34343594, + 34352693, + 34366024, + 34417293, + 34502808, + 34759658, + 34770702, + 34770734, + 34922556, + 34923577, + 35276580, + 35276581, + 35457507, + 35472957, + 35475440, + 35496249, + 35839520, + 35871385, + 36084057, + 36194311, + 36203715, + 36208174, + 36209749, + 36384830, + 36503402, + 36503414, + 36503427, + 36512623, + 36528830, + 36528860, + 36529889, + 36529896, + 36529902, + 36531182, + 36537026, + 36537035, + 36543479, + 36543480, + 36543481, + 36543652, + 36543653, + 36543654, + 36543655, + 36543656, + 36543657, + 36543658, + 36543659, + 36543660, + 36543661, + 36544783, + 36545004, + 36545016, + 36545028, + 36546193, + 36550010, + 36557167, + 36575681, + 36576483, + 36576486, + 36576495, + 36576497, + 36667302, + 36786213, + 36837211, + 37048096, + 37048098, + 37048099, + 37048101, + 37048105, + 37048106, + 37048109, + 37048113, + 37115615, + 37134754, + 37218243, + 37271884, + 37272651, + 37289728, + 37291256, + 37326518, + 37341816, + 37346719, + 37347295, + 37367290, + 37457475, + 37628079, + 37660837, + 37736020, + 37736309, + 37760536, + 37808472, + 37819842, + 37867898, + 37900351, + 38058475, + 38264572, + 38475627, + 38475628, + 38501807, + 38532527, + 38637356, + 38646116, + 38646136, + 38653859, + 38654008, + 38654081, + 38654574, + 38654599, + 38654601, + 38654602, + 38654603, + 39143252, + 39267238, + 39360030, + 39360052, + 39383541, + 39383542, + 39383545, + 39994471, + 40411687, + 40411689, + 40512165, + 40593079, + 40593093, + 40720254, + 40720436, + 40725831, + 41321997, + 41552440, + 41600579, + 41601121, + 41617224, + 41618001, + 41726020, + 41820063, + 41828582, + 41850556, + 42023273, + 42165883, + 42248389, + 42284528, + 42330050, + 42330160, + 42330563, + 42330578, + 42330590, + 42330640, + 42331590, + 42331862, + 42362267, + 42378225, + 42378951, + 42380871, + 42381162, + 42381220, + 42382055, + 42382784, + 42383609, + 42383670, + 42383673, + 42383738, + 42384724, + 42386379, + 42416801, + 42422386, + 42422522, + 42435606, + 42435622, + 42435633, + 42435648, + 42435661, + 42435676, + 42435690, + 42435696, + 42435708, + 42435712, + 42435718, + 42435728, + 42435737, + 42435751, + 42435758, + 42435767, + 42435781, + 42435791, + 42435803, + 42435811, + 42435816, + 42435823, + 42435833, + 42435845, + 42435870, + 42435880, + 42435885, + 42435901, + 42435918, + 42435929, + 42435955, + 42435962, + 42435966, + 42435972, + 42436004, + 42436011, + 42436019, + 42436026, + 42437444, + 42437515, + 42445832, + 42446212, + 42446675, + 42446976, + 42447237, + 42450817, + 42451435, + 42451469, + 42476114, + 42478583, + 42494658, + 42516523, + 42555853, + 42555855, + 42555965, + 42566200, + 42566251, + 42566525, + 42566816, + 42566826, + 42567884, + 42569804, + 42569806, + 42569808, + 42569810, + 42569816, + 42569822, + 42569825, + 42569827, + 42570222, + 42570587, + 42570679, + 42573001, + 42573005, + 42573210, + 42573400, + 42604669, + 42615480, + 42638916, + 42664731, + 42684363, + 42708564, + 42708723, + 42736935, + 42736944, + 42736950, + 42736957, + 42736964, + 42736996, + 42737033, + 42737130, + 42737895, + 42738530, + 42782602, + 42792941, + 42792950, + 42792953, + 42792976, + 42797222, + 42839431, + 42839646, + 42840893, + 42840896, + 42840905, + 42840913, + 42840915, + 42841068, + 42841307, + 42844242, + 42849166, + 42864372, + 42865135, + 42865242, + 42865476, + 42865509, + 42865939, + 42866709, + 42871592, + 42887652, + 42887658, + 42887662, + 42887669, + 42887674, + 42889798, + 42906576, + 42998131, + 43025629, + 43132583, + 43214145, + 43249845, + 43325621, + 43339738, + 43339949, + 43340216, + 43343660, + 43353984, + 43363510, + 43364809, + 43366494, + 43366572, + 43366646, + 43366713, + 43368614, + 43368869, + 43369139, + 43371830, + 43371841, + 43390851, + 43390871, + 43391674, + 43392567, + 43395544, + 43420652, + 43422527, + 43425767, + 43453121, + 43501200, + 43575933, + 43819812, + 43820054, + 43820203, + 43848345, + 43854095, + 43993561, + 43998939, + 44001178, + 44001242, + 44001793, + 44001971, + 44002078, + 44062790, + 44090859, + 44094087, + 44164310, + 44164312, + 44231317, + 44243731, + 44244040, + 44244416, + 44244425, + 44244553, + 44244695, + 44244728, + 44245177, + 44245337, + 44266072, + 44266092, + 44335653, + 44347046, + 44347202, + 44347312, + 44347316, + 44347338, + 44347378, + 44347449, + 44348188, + 44399304, + 44640387, + 44640504, + 45145559, + 45186957, + 45249959, + 45263524, + 45263525, + 45263526, + 45263528, + 45263529, + 45263530, + 45263532, + 45263533, + 45391376, + 45682728, + 45682729, + 45682730, + 45812179, + 45952018, + 45952989, + 45952999, + 45953007, + 45953083, + 45953087, + 45953099, + 45953180, + 45953793, + 45954219, + 45954232, + 46038388, + 46133656, + 46294349, + 46501370, + 46501451, + 47027990, + 47117018, + 47153183, + 47346867, + 47370680, + 47957067, + 47957068, + 47957069, + 47985656, + 47985657, + 48018496, + 48018506, + 48237179, + 48248075, + 48370419, + 48583058, + 48594276, + 48594339, + 48719754, + 48834823, + 48834827, + 48865480, + 48942859, + 48962406, + 48964432, + 48964448, + 48964454, + 48989605, + 48992882, + 49096792, + 49097136, + 49098575, + 49157522, + 49283232, + 49577191, + 49614371, + 49615025, + 49639025, + 49698962, + 49698963, + 49698965, + 49698967, + 49721586, + 49735344, + 49780200, + 49786913, + 49786914, + 49787377, + 49787378, + 49789246, + 49856587, + 50402957, + 50469498, + 50469499, + 50546756, + 50546768, + 50546773, + 50546774, + 50546775, + 50546780, + 50635293, + 50655321, + 50680145, + 50680147, + 50680187, + 50680212, + 50770355, + 50772482, + 50772653, + 50772729, + 50776490, + 50834318, + 50883343, + 50900244, + 51093345, + 51144194, + 51144195, + 51149082, + 51404314, + 51418990, + 51673493, + 51774905, + 51833752, + 51916566, + 51981301, + 52101897, + 52140548, + 52191515, + 52194718, + 52554485, + 52554654, + 52554680, + 52615626, + 52637839, + 53016032, + 53148197, + 53471069, + 53560045, + 53832630, + 53992460, + 54022932, + 54025092, + 54047967, + 54105527, + 54170320, + 54170361, + 54292119, + 54409640, + 54513327, + 54513334, + 54513338, + 54573744, + 54581172, + 54581215, + 54967143, + 55074241, + 55098902, + 55099066, + 55099082, + 55102759, + 55118869, + 55118902, + 55439405, + 55516376, + 55516377, + 55516378, + 55516380, + 55602605, + 56141157, + 56141174, + 56476622, + 56476637, + 56608152, + 56685834, + 56878878, + 56929501, + 57199917, + 57205491, + 57205492, + 57361354, + 57547634, + 57606092, + 57762322, + 58117974, + 58231215, + 58546658, + 59050068, + 59338541, + 59421844, + 59467726, + 59582602, + 60618894, + 60791369, + 60834312, + 60834349, + 60834876, + 60836138, + 60836566, + 60979270, + 61495652, + 61532135, + 61573203, + 61573288, + 61573391, + 61574296, + 61615939, + 61736975, + 61740434, + 61750523, + 61877539, + 61937453, + 62010889, + 62010890, + 62211424, + 62415446, + 62653258, + 62653264, + 62694862, + 62694863, + 62694866, + 62694868, + 62694869, + 62694870, + 62694874, + 62694876, + 62694881, + 62709167, + 62811056, + 62811057, + 62826238, + 62830135, + 62830142, + 62830180, + 62861828, + 62955033, + 62955396, + 62955701, + 62955772, + 62955950, + 62956005, + 62956166, + 62956550, + 62956754, + 62956773, + 62956843, + 62956872, + 62957037, + 62957528, + 62957711, + 62957785, + 62958873, + 62958903, + 62959411, + 62959741, + 63538833, + 63672968, + 63716127, + 63716129, + 63716130, + 63764319, + 63810754, + 63923546, + 64013573, + 64082317, + 64403832, + 64490255, + 64606438, + 64641477, + 64779196, + 64779197, + 64813308, + 64825997, + 64826005, + 64984136, + 65249210, + 65685894, + 65685902, + 65789183, + 66120675, + 66123954, + 66214386, + 66314399, + 66367046, + 66367047, + 66367053, + 66367055, + 66411272, + 66451459, + 66478508, + 66644486, + 66644496, + 66644810, + 66644823, + 66910065, + 66910073, + 66910084, + 66910085, + 66910086, + 66910087, + 66910090, + 66910092, + 66910125, + 66910132, + 66910134, + 66916447, + 66918876, + 66919864, + 66920019, + 66920025, + 66989812, + 66989867, + 66993455, + 66993464, + 66994096, + 66994122, + 67030209, + 67056251, + 67056254, + 67175530, + 67241454, + 67241455, + 67241456, + 67241459, + 67241460, + 67241461, + 67400675, + 67508221, + 67508243, + 67508270, + 67808008, + 67808065, + 67907358, + 68036016, + 68036017, + 68341257, + 68341278, + 68670267, + 68684953, + 68774602, + 68894104, + 68905904, + 68987780, + 68987783, + 68987784, + 68989887, + 68990601, + 69195582, + 69195584, + 69204663, + 69457832, + 69623568, + 70053790, + 70310770, + 70330475, + 70330476, + 70483773, + 70537884, + 70537893, + 70537899, + 70542349, + 70638013, + 70943454, + 70943455, + 70997979, + 71166697, + 71211404, + 71550490, + 71771324, + 72064954, + 72542566, + 72574632, + 72660375, + 72703925, + 72703931, + 72703937, + 72711734, + 72727639, + 72857601, + 72996315, + 73147810, + 73171958, + 73543558, + 73543560, + 73543561, + 73543562, + 73560621, + 73563677, + 73563679, + 73565026, + 73565384, + 73627891, + 73627892, + 73627902, + 73695012, + 73695196, + 74011168, + 74019568, + 74248545, + 74248574, + 74248693, + 74248727, + 74341856, + 74455109, + 74552190, + 74621757, + 74747529, + 74824512, + 75081198, + 75428479, + 75479679, + 75666529, + 76465543, + 76585941, + 76703793, + 76711526, + 76920658, + 76951750, + 76951751, + 76951752, + 77185175, + 77192576, + 77244236, + 77265807, + 77288307, + 77489738, + 77507503, + 77513074, + 77726747, + 77726748, + 77726749, + 77782074, + 77885772, + 78106104, + 78106204, + 78155255, + 78810811, + 78929852, + 78930756, + 79112440, + 79163392, + 79791134, + 79791136, + 79791138, + 79791139, + 79791140, + 79791142, + 80070860, + 80128901, + 80149859, + 80206287, + 80207233, + 80216685, + 80294980, + 80347307, + 80380792, + 80542253, + 80547281, + 80669480, + 80890834, + 80957063, + 81071882, + 81094324, + 81171410, + 81356079, + 81687671, + 81741527, + 81807113, + 81807114, + 81807116, + 81807735, + 81807736, + 81807737, + 81807740, + 81808024, + 82036539, + 82139893, + 82491947, + 82633682, + 82654224, + 82932078, + 82932080, + 82943596, + 83081643, + 83081647, + 83081650, + 83121749, + 83121750, + 83121752, + 83121753, + 83229417, + 83327057, + 83365994, + 83365996, + 83366933, + 83366934, + 83366939, + 83367515, + 83367516, + 83367518, + 83367522, + 83367524, + 83367525, + 83367526, + 83368187, + 83368189, + 83368190, + 83368193, + 83528931, + 83722112, + 84073106, + 84073110, + 84209211, + 84595440, + 84818771, + 84832506, + 84837974, + 84920032, + 84920033, + 85042186, + 85084057, + 85416846, + 85438387, + 85515957, + 85768588, + 86192832, + 86256525, + 86256528, + 86607858, + 86609693, + 86704528, + 86705444, + 86705799, + 86706036, + 86734031, + 86734037, + 86734709, + 86743299, + 86743994, + 86744053, + 86887034, + 86888918, + 86889194, + 86889388, + 86907141, + 86907852, + 86908012, + 86909543, + 87106046, + 87672841, + 87915023, + 87996699, + 87996704, + 88257667, + 88876357, + 88876358, + 88884667, + 89020036, + 89287807, + 89322634, + 89600993, + 90020510, + 90452059, + 90452067, + 90866782, + 90955700, + 91011237, + 91021970, + 91455467, + 91916566, + 91916614, + 92039339, + 92106352, + 92229949, + 92229950, + 92229955, + 92230305, + 92516036, + 92959915, + 92980446, + 93094009, + 93363242, + 93511488, + 93543664, + 93754413, + 93755794, + 93755845, + 93755849, + 93755851, + 93755876, + 93755946, + 93756070, + 93756085, + 93756155, + 93756386, + 93756408, + 93756458, + 93756467, + 93756469, + 93756475, + 93756491, + 93756532, + 93756547, + 93756610, + 93900159, + 93902043, + 94212904, + 94622560, + 94895673, + 95056761, + 95208926, + 95466872, + 95493583, + 95509294, + 95931284, + 95932422, + 95933458, + 96195193, + 96295584, + 96748213, + 97013754, + 97064444, + 97136543, + 97137896, + 97380442, + 97482123, + 97817536, + 98011522, + 98139994, + 98873743, + 98873745, + 98873748, + 98879960, + 99642045, + 99642074, + 99726149, + 99894412, + 100327877, + 100332834, + 100531978, + 102165992, + 103508218, + 103539449, + 103586076, + 104232694, + 104272695, + 104311324, + 104311413, + 105643147, + 105643148, + 105643149, + 105643153, + 105643154, + 105643155, + 105643156, + 105643157, + 105643158, + 105686311, + 105960179, + 106230760, + 106261604, + 106391917, + 106404286, + 106467469, + 106583135, + 106926374, + 107027615, + 107090315, + 107090316, + 107184557, + 107343755, + 107740278, + 107894820, + 107894821, + 108081122, + 108113888, + 108214445, + 108306024, + 108778466, + 108913571, + 109159732, + 109434269, + 109555373, + 110057904, + 110359666, + 110360192, + 110360204, + 110360472, + 110360478, + 110360483, + 110673375, + 110914692, + 111257836, + 111344495, + 111419226, + 112453773, + 112949590, + 113122418, + 113373544, + 113600805, + 114378576, + 114480829, + 114505350, + 114616471, + 114840432, + 115569487, + 115705762, + 115762176, + 115762286, + 115762492, + 115792767, + 115933381, + 116612196, + 117217851, + 117217888, + 117277094, + 117717799, + 117950064, + 118040092, + 118336329, + 118603175, + 119042963, + 119459733, + 120021781, + 120062215, + 120115380, + 120132863, + 120133112, + 120340107, + 120713774, + 121130232, + 121441651, + 121449881, + 121601911, + 121608235, + 121608236, + 122045014, + 122045019, + 122394153, + 122892708, + 122898810, + 123618104, + 124481488, + 124481655, + 125164513, + 126590711, + 126590712, + 126590713, + 126590715, + 126590719, + 126593630, + 126802978, + 127631689, + 128168217, + 129121996, + 129121997, + 129121998, + 129121999, + 129122000, + 129122042, + 129580202, + 129580313, + 129580323, + 129580326, + 129580327, + 129580522, + 129580525, + 129580527, + 129580528, + 129580529, + 129580530, + 129580532, + 129580533, + 129580535, + 129580536, + 129580537, + 129580538, + 129589469, + 129589475, + 129589737, + 129589742, + 129606076, + 129606082, + 129619172, + 129678496, + 129678533, + 129678534, + 129678535, + 129678537, + 129678538, + 129678702, + 129678704, + 129678705, + 129678708, + 129678710, + 129678711, + 129678712, + 129678713, + 129678714, + 129678717, + 129678718, + 129678727, + 129678787, + 129678811, + 130487701, + 130569516, + 131323125, + 131324437, + 131324491, + 131661059, + 131966204, + 132364119, + 132364120, + 132364121, + 132364128, + 132364129, + 132364133, + 132364138, + 132364139, + 132364140, + 132364147, + 132364149, + 132364150, + 132364155, + 132364156, + 132364160, + 132364163, + 132364164, + 132364165, + 132364166, + 132364167, + 132364175, + 132364178, + 132364180, + 132364186, + 132569545, + 132569550, + 132569553, + 132569558, + 132569561, + 132569565, + 132569566, + 132569568, + 132569576, + 132569577, + 132754858, + 132789003, + 132942800, + 132955842, + 133030008, + 133071907, + 133209375, + 133698412, + 133755469, + 135979707, + 136064739, + 136065178, + 136065316, + 136066378, + 136137676, + 136137883, + 136138049, + 136138367, + 136152775, + 136398561, + 138173669, + 138173671, + 138173672, + 138173674, + 138173675, + 138173676, + 138173678, + 138173682, + 138173683, + 138173689, + 138173690, + 138173691, + 138173694, + 138173696, + 138323925, + 138323926, + 138323927, + 138323933, + 138323934, + 138323935, + 138323938, + 138323939, + 138323940, + 138414185, + 138514437, + 139367221, + 139439904, + 139440100, + 139649096, + 140205060, + 140855587, + 141078337, + 141424866, + 141745475, + 141745477, + 141792600, + 142016669, + 142286448, + 142650376, + 143654357, + 143854501, + 143871648, + 143871652, + 143871656, + 143871668, + 144151271, + 144211162, + 145196909, + 145372044, + 145867539, + 145867541, + 146799019, + 146799020, + 146799021, + 146903579, + 147027222, + 147262278, + 147262279, + 147262281, + 147406797, + 147465250, + 147588903, + 147588904, + 148053853, + 148583033, + 148717572, + 148738730, + 149021114, + 149257776, + 149298329, + 149298409, + 149411194, + 149852168, + 149948248, + 150174440, + 150625487, + 150743007, + 151224315, + 151918804, + 152785735, + 153023481, + 153105129, + 154123837, + 154505994, + 154801903, + 155255926, + 155684847, + 156942951, + 156942959, + 156955644, + 156955944, + 157848022, + 157848023, + 157848786, + 157848787, + 157848789, + 157848792, + 157848793, + 158140879, + 159099740, + 160221206, + 160394838, + 160916408, + 161046998, + 161962965, + 162225144, + 162470304, + 162571850, + 162584208, + 162586759, + 162689080, + 162848170, + 162970689, + 162970714, + 163007381, + 163007388, + 164088551, + 164454068, + 164647386, + 165466198, + 166831526, + 166831534, + 167672274, + 169651383, + 169651627, + 169681240, + 169681268, + 169718594, + 170186331, + 170187011, + 170269640, + 170861649, + 171032747, + 171329631, + 171394372, + 171394375, + 171923181, + 171923182, + 173198944, + 173389400, + 174418452, + 174631701, + 176935629, + 177406683, + 177406684, + 177492410, + 177521740, + 177663991, + 178280024, + 178280025, + 178280026, + 178575728, + 178575729, + 178640931, + 178641013, + 178732137, + 178864946, + 178899614, + 179522491, + 179537821, + 179987676, + 180037170, + 180702599, + 181093822, + 181406823, + 182275478, + 182275479, + 182873103, + 182936956, + 183013815, + 183244417, + 183754396, + 183890568, + 183980732, + 184014579, + 184445372, + 184511099, + 185236103, + 185236144, + 188017261, + 188358750, + 188358894, + 189429830, + 191901488, + 192288828, + 192288829, + 192288830, + 192288833, + 192288835, + 192288842, + 192288844, + 192288847, + 192921815, + 193208576, + 193366451, + 193598502, + 193598504, + 195160331, + 195160336, + 195160349, + 195726454, + 195726455, + 195726456, + 196169291, + 196169292, + 197775780, + 197827548, + 198060253, + 198060254, + 198096374, + 198242565, + 198242567 + ] +} diff --git a/signal_bot/traps_command.py b/signal_bot/traps_command.py new file mode 100644 index 0000000..96dafd1 --- /dev/null +++ b/signal_bot/traps_command.py @@ -0,0 +1,116 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""The !pułapka command: a photograph of the device, and what it is. + +The joke is literalism -- whatever the word meant to whoever typed it, what +comes back is a bear trap, a mousetrap, a snare, a cage, a fish weir. The +caption is not an apology for the joke, it is the answer to the next +question: Commons has plenty of files whose subject is genuinely unguessable +("Molchreuse", "K Pañayacu 0811"), and a newt trap nobody can identify is +just a photograph of a basket. + +Nothing is cached. !papież caches because 267 portraits are a closed set a +group works through; a uniform draw from a couple of thousand traps warms a +cache essentially never, so the disk would fill with images nobody asks for +twice. This works the way !kot and !pies do instead: fetch, post, forget. +""" + +from __future__ import annotations + +import json +import secrets +from base64 import b64encode +from pathlib import Path +from typing import TYPE_CHECKING, Final + +from signal_bot.animals import load_aliases +from signal_bot.commons import caption_for, imageinfo_params, page_of +from signal_bot.endpoints import THUMB_WIDTH + +if TYPE_CHECKING: + from collections.abc import Sequence + + from signal_bot.context import CommandContext + from signal_bot.decorators import Decorator + from signal_bot.http_client import ImageFetcher + from signal_bot.registry import CommandRegistry, Handler + +COMMAND_NAME: Final = "trap" +DESCRIPTION: Final = "losowe zdjęcie pułapki" +TRAPS_FILE: Final = Path(__file__).with_name("traps.json") + + +def load_traps(path: Path = TRAPS_FILE) -> tuple[int, ...]: + """Read the trap pool. + + The file holds Commons page ids -- bare integers -- and that is checked + here rather than assumed. Filenames would be free text carrying + photographers' and sitters' names, and a name committed here collides + sooner or later with a label somebody minted from the chat, at which + point scripts/check_personal_data.py refuses every commit for good over + data nobody can edit by hand. An integer cannot do that, and this is the + guard that keeps it an integer. + + Args: + path: Where to read the pool from. Defaults to the shipped file. + + Returns: + Every Commons page id in the pool. + + Raises: + ValueError: If the pool is empty or holds anything but integers. + """ + document = json.loads(path.read_text(encoding="utf-8")) + traps = document["traps"] + if not traps or not all(isinstance(page, int) for page in traps): + msg = f"{path} must hold a non-empty list of Commons page ids" + raise ValueError(msg) + return tuple(traps) + + +def random_trap(traps: Sequence[int]) -> int: + """Return one page id, uniformly at random. + + secrets rather than random for the reason animals.paged_url gives: the + choice is not security-sensitive, but ruff's ALL rule set flags `random` + and this repository forbids the noqa that would silence it. + """ + return traps[secrets.randbelow(len(traps))] + + +def trap_handler( + fetcher: ImageFetcher, + api_url: str, + traps: Sequence[int] | None = None, +) -> Handler: + """Build the handler that posts one random trap.""" + pool = load_traps() if traps is None else tuple(traps) + + async def handler(ctx: CommandContext) -> None: + # One request, because the caption and the picture have to describe + # the same file; two would be a chance for them to disagree. + document = await fetcher.fetch_json( + api_url, + imageinfo_params(THUMB_WIDTH, page_id=random_trap(pool), describe=True), + ) + page = page_of(document) + image = await fetcher.fetch_image(str(page["imageinfo"][0]["thumburl"])) + await ctx.reply_image(b64encode(image).decode("ascii"), caption_for(page)) + + return handler + + +def register_trap_command( + registry: CommandRegistry, + fetcher: ImageFetcher, + api_url: str, + *, + decorate: Decorator | None = None, + aliases: dict[str, list[str]] | None = None, + traps: Sequence[int] | None = None, +) -> None: + """Register !pułapka under every trigger aliases.json gives it.""" + table = load_aliases() if aliases is None else aliases + handler = trap_handler(fetcher, api_url, traps) + if decorate is not None: + handler = decorate(handler) + registry.register(COMMAND_NAME, table[COMMAND_NAME], handler, DESCRIPTION) diff --git a/tests/test_commons.py b/tests/test_commons.py new file mode 100644 index 0000000..a1f25ba --- /dev/null +++ b/tests/test_commons.py @@ -0,0 +1,123 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""The imageinfo query both Commons commands send.""" + +from __future__ import annotations + +import pytest + +from signal_bot.commons import ( + CAPTION_LIMIT, + SUFFIXES, + THUMB_PATH, + caption_for, + imageinfo_params, + page_of, +) +from signal_bot.http_client import ImageFetchError + + +def test_a_file_can_be_named_by_title() -> None: + params = imageinfo_params(640, title="File:1868 Mausefalle.jpg") + assert params["titles"] == "File:1868 Mausefalle.jpg" + assert "pageids" not in params + assert params["iiurlwidth"] == "640" + assert params["prop"] == "imageinfo" + + +def test_a_file_can_be_named_by_page_id() -> None: + # The page id arrives as an int and has to reach Commons as a string; + # sending the int would raise inside httpx rather than here. + params = imageinfo_params(480, page_id=111419226) + assert params["pageids"] == "111419226" + assert "titles" not in params + + +@pytest.mark.parametrize( + ("title", "page_id"), + [(None, None), ("File:x.jpg", 1)], +) +def test_naming_a_file_neither_way_or_both_ways_is_refused( + title: str | None, page_id: int | None +) -> None: + # Preferring one silently would make a caller's mistake look like a 404, + # which is the one failure shape that already exists downstream. + with pytest.raises(ValueError, match="exactly one"): + imageinfo_params(640, title=title, page_id=page_id) + + +def test_the_thumbnail_path_is_where_commons_actually_puts_it() -> None: + # Pinned because both callers walk it and neither can see it fail until + # a real fetch returns something unwalkable. + assert THUMB_PATH == ("query", "pages", 0, "imageinfo", 0, "thumburl") + + +def test_the_suffix_allowlist_is_what_signal_can_render() -> None: + assert ".jpg" in SUFFIXES + assert ".svg" not in SUFFIXES + + +def _page( + description: str | None = None, *, title: str = "File:Molchreuse.JPG", url: str = "" +) -> dict[str, object]: + """One imageinfo page, shaped the way Commons shapes it.""" + info: dict[str, object] = {"thumburl": "https://cdn/x.jpg"} + if url: + info["descriptionurl"] = url + if description is not None: + info["extmetadata"] = {"ImageDescription": {"value": description}} + return {"title": title, "imageinfo": [info]} + + +def test_asking_for_a_description_widens_the_query() -> None: + plain = imageinfo_params(640, page_id=1) + described = imageinfo_params(640, page_id=1, describe=True) + assert plain["iiprop"] == "url" + assert "iiextmetadatafilter" not in plain + assert described["iiprop"] == "url|extmetadata" + assert described["iiextmetadatafilter"] == "ImageDescription" + + +def test_the_caption_strips_markup_and_links_the_page() -> None: + page = _page("Eine Molchreuse & ein Korb", url="https://commons/File:M.JPG") + assert caption_for(page) == "Eine Molchreuse & ein Korb\nhttps://commons/File:M.JPG" + + +def test_the_catalogue_list_is_cut_and_the_sentence_kept() -> None: + # The Fisheries records append their filing fields as an HTML list. + page = _page("Round Fyke (Bollreuse). Prussia\n
  • Subject: Fishing nets
") + assert caption_for(page) == "Round Fyke (Bollreuse). Prussia" + + +@pytest.mark.parametrize("description", [None, "", "
  • Subject: only filing
"]) +def test_a_description_that_says_nothing_falls_back_to_the_title(description: str | None) -> None: + assert caption_for(_page(description)) == "Molchreuse" + + +def test_a_long_description_is_cut_rather_than_burying_the_photo() -> None: + page = _page("word " * 200) + caption = caption_for(page) + assert caption.endswith("…") + # rstrip() before the ellipsis means the exact length depends on where + # the cut landed; the ceiling is what matters. + assert len(caption) <= CAPTION_LIMIT + 1 + + +def test_a_page_commons_could_not_find_is_an_image_fetch_error() -> None: + # A deleted or renumbered page id comes back as {"missing": true}. + for document in ({"query": {"pages": [{"missing": True}]}}, {"query": {"pages": []}}, {}, []): + with pytest.raises(ImageFetchError, match="named no image"): + page_of(document) + + +def test_the_page_comes_back_when_commons_did_find_it() -> None: + assert page_of({"query": {"pages": [_page("x")]}})["title"] == "File:Molchreuse.JPG" + + +def test_a_template_that_is_not_a_description_falls_back_to_the_title() -> None: + # Measured on a Maltese fish trap: the filename said more than Commons' + # own description field did. + page = _page( + "This photo has been taken in the country: Malta", + title="File:Hand woven fish trap.jpg", + ) + assert caption_for(page) == "Hand woven fish trap" diff --git a/tests/test_help_command.py b/tests/test_help_command.py index 83629c3..d8d4c9d 100644 --- a/tests/test_help_command.py +++ b/tests/test_help_command.py @@ -33,6 +33,7 @@ from signal_bot.portraits import PortraitCache from signal_bot.registry import CommandRegistry from signal_bot.stats.commands import register_stats_commands from signal_bot.suggest_command import register_suggest_command +from signal_bot.traps_command import register_trap_command from tests import pack_fixture from tests.conftest import SECOND_GROUP, WATCHED_GROUP, envelope @@ -64,6 +65,7 @@ def full_registry() -> CommandRegistry: fetcher = _offline_fetcher() register_animal_commands(registry, fetcher) register_pope_command(registry, PortraitCache(fetcher, DEFAULT_COMMONS_API_URL)) + register_trap_command(registry, fetcher, DEFAULT_COMMONS_API_URL, traps=(1,)) register_stats_commands(registry) register_suggest_command(registry) register_help_command(registry) diff --git a/tests/test_traps_command.py b/tests/test_traps_command.py new file mode 100644 index 0000000..c87e6b1 --- /dev/null +++ b/tests/test_traps_command.py @@ -0,0 +1,191 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""The !pułapka command: a photograph of the device, with nothing attached.""" + +from __future__ import annotations + +import base64 +import json +from typing import TYPE_CHECKING + +import httpx +import pytest + +from signal_bot.context import CommandContext +from signal_bot.decorators import DISPLAY_MARGIN_SECONDS, cooldown +from signal_bot.http_client import ImageFetcher +from signal_bot.models import parse_envelope +from signal_bot.registry import CommandRefusedError, CommandRegistry +from signal_bot.traps_command import ( + COMMAND_NAME, + TRAPS_FILE, + load_traps, + random_trap, + register_trap_command, +) +from tests.conftest import WATCHED_GROUP, envelope + +if TYPE_CHECKING: + from pathlib import Path + + from signal_bot.config import Config + from signal_bot.store import Store + from signal_bot.transport_fake import FakeTransport + +IMAGE = b"trap-bytes" +API = "https://commons.example/w/api.php" +ALIASES = {COMMAND_NAME: ["!pułapka", "!pulapka", "!trap"]} +POOL = (111419226, 124481655, 151918804) + + +def _fetcher(seen: list[httpx.URL] | None = None) -> ImageFetcher: + def handler(request: httpx.Request) -> httpx.Response: + if "commons" in str(request.url): + if seen is not None: + seen.append(request.url) + page = { + "title": "File:Molchreuse.JPG", + "imageinfo": [ + { + "thumburl": "https://cdn/trap.jpg", + "descriptionurl": "https://commons.wikimedia.org/wiki/File:Molchreuse.JPG", + "extmetadata": { + "ImageDescription": {"value": "Eine Molchreuse"}, + }, + } + ], + } + return httpx.Response(200, json={"query": {"pages": [page]}}) + return httpx.Response(200, content=IMAGE) + + return ImageFetcher(httpx.AsyncClient(transport=httpx.MockTransport(handler))) + + +def _ctx(transport: FakeTransport, store: Store, config: Config, text: str) -> CommandContext: + message = parse_envelope(envelope(text=text)) + assert message is not None + return CommandContext( + message=message, + group_id=WATCHED_GROUP, + transport=transport, + store=store, + config=config, + command_name=COMMAND_NAME, + now=100.0, + day="2026-08-31", + ) + + +@pytest.mark.parametrize("trigger", ["!pułapka", "!pulapka", "!trap"]) +def test_every_alias_resolves(trigger: str) -> None: + registry = CommandRegistry() + register_trap_command(registry, _fetcher(), API, aliases=ALIASES, traps=POOL) + + command = registry.resolve(trigger) + assert command is not None + assert command.name == COMMAND_NAME + + +def test_the_real_alias_file_carries_the_command() -> None: + registry = CommandRegistry() + register_trap_command(registry, _fetcher(), API, traps=POOL) + # The Polish spelling is the primary one !pomoc prints; the English + # !trap is the word that prompted the command in the first place. + assert registry.resolve("!pułapka") is not None + assert registry.resolve("!trap") is not None + + +async def test_it_posts_an_image_captioned_with_what_the_thing_is( + transport: FakeTransport, store: Store, config: Config +) -> None: + registry = CommandRegistry() + register_trap_command(registry, _fetcher(), API, aliases=ALIASES, traps=POOL) + command = registry.resolve("!trap") + assert command is not None + + await command.handler(_ctx(transport, store, config, "!trap")) + + sent = transport.sent[0] + assert sent.recipient == WATCHED_GROUP + assert sent.image_base64 is not None + assert base64.b64decode(sent.image_base64) == IMAGE + assert len(transport.sent) == 1 + # Commons is full of files whose subject is unguessable, so the caption + # says what it is and links the page that says more. Tags come out. + assert sent.caption == ( + "Eine Molchreuse\nhttps://commons.wikimedia.org/wiki/File:Molchreuse.JPG" + ) + + +async def test_it_names_the_file_by_page_id_and_never_by_title( + transport: FakeTransport, store: Store, config: Config +) -> None: + # A title would be free text carrying people's names into the repository; + # see load_traps. This is the assertion that keeps the query on page ids. + seen: list[httpx.URL] = [] + registry = CommandRegistry() + register_trap_command(registry, _fetcher(seen), API, aliases=ALIASES, traps=POOL) + command = registry.resolve("!trap") + assert command is not None + + await command.handler(_ctx(transport, store, config, "!trap")) + + assert len(seen) == 1 + assert seen[0].params["pageids"] in {str(page) for page in POOL} + assert "titles" not in seen[0].params + + +async def test_the_cooldown_decorator_applies( + transport: FakeTransport, store: Store, config: Config +) -> None: + registry = CommandRegistry() + register_trap_command( + registry, + _fetcher(), + API, + decorate=cooldown(10, "WAIT {seconds}"), + aliases=ALIASES, + traps=POOL, + ) + store.record_use(WATCHED_GROUP, "uuid-alice", COMMAND_NAME, 100.0, "2026-08-31") + command = registry.resolve("!trap") + assert command is not None + + with pytest.raises(CommandRefusedError): + await command.handler(_ctx(transport, store, config, "!trap")) + + assert transport.texts == [f"WAIT {10 + DISPLAY_MARGIN_SECONDS}"] + + +def test_the_draw_actually_varies() -> None: + """0.0.3 posted one identical gecko forever; that is what this catches.""" + drawn = {random_trap(POOL) for _ in range(20)} + assert len(drawn) > 1 + + +def test_the_shipped_pool_is_page_ids_and_nothing_else() -> None: + """The personal-data invariant, as a unit test. + + A filename here would carry photographers' and sitters' names into a + public repository, and one of them colliding with a label somebody minted + from the chat wedges check_personal_data.py permanently. Integers cannot. + """ + raw = json.loads(TRAPS_FILE.read_text(encoding="utf-8"))["traps"] + assert raw + assert all(isinstance(page, int) for page in raw) + assert len(set(raw)) == len(raw) + assert raw == sorted(raw) + + +def test_the_shipped_pool_is_large_enough_to_be_worth_drawing_from() -> None: + # kuhy asked for a set "as big as possible", and a regeneration that + # silently truncated would still pass every other test here. + assert len(load_traps()) >= 500 + + +@pytest.mark.parametrize("traps", [[], ["111419226.jpg"], [None]]) +def test_a_pool_that_is_not_page_ids_is_refused(tmp_path: Path, traps: list[object]) -> None: + path = tmp_path / "traps.json" + path.write_text(json.dumps({"traps": traps}), encoding="utf-8") + + with pytest.raises(ValueError, match="page ids"): + load_traps(path) diff --git a/tool/gen_traps.py b/tool/gen_traps.py new file mode 100755 index 0000000..edc2add --- /dev/null +++ b/tool/gen_traps.py @@ -0,0 +1,216 @@ +#!/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()) diff --git a/tool/trap_categories.json b/tool/trap_categories.json new file mode 100644 index 0000000..97a4458 --- /dev/null +++ b/tool/trap_categories.json @@ -0,0 +1,58 @@ +{ + "_comment": "Which Commons categories !pulapka draws from. Data, not source, for the reason aliases.json is: changing the policy should never mean touching Python. Every seed is checked against Commons before the harvest starts -- Snares, Man traps, Hunting traps, Wasp traps and Animal trapping all look like plausible names and none of them exists, so a typo would otherwise remove a whole subject in silence. by-function and by-animal are container categories whose immediate children hold the leg-hold, deadfall and per-species traps.", + "_excluded_comment": "Pruned as whole subtrees, never enqueued and never read. The command posts an attachment with no caption into a group chat, so this list is the only place the content of that attachment can be decided at all. Camera traps are photographs taken *by* a trap and drown the pool; booby traps reach wartime propaganda including a poster whose title carries a racial slur; the Wolfsangel is a heraldic charge that is also a neo-Nazi symbol; the mice categories are animals dying; the rest are geology, botany, paintings, architecture and bait rather than devices.", + "seeds": [ + "Category:Traps", + "Category:Traps by function", + "Category:Traps by animal", + "Category:Mousetraps", + "Category:Rat traps", + "Category:Mole traps", + "Category:Bear traps", + "Category:Leghold traps", + "Category:Snare traps", + "Category:Cage traps", + "Category:Live capture traps", + "Category:Pitfall traps", + "Category:Trapping pits", + "Category:Deadfall traps", + "Category:Mantraps", + "Category:Fish traps", + "Category:Lobster traps", + "Category:Crab traps", + "Category:Eel traps", + "Category:Fyke nets", + "Category:Fly traps", + "Category:Flypaper", + "Category:Insect traps" + ], + "excluded": [ + "Category:Animals in traps", + "Category:Baits", + "Category:Booby traps", + "Category:Booby traps in the Vietnam War", + "Category:Camera traps", + "Category:Carnivorous plants with prey", + "Category:Fishing bait", + "Category:Fixed gillnets", + "Category:Glue traps", + "Category:Mammals in traps", + "Category:Mice in cage traps", + "Category:Mice in glue mousetraps", + "Category:Mice in spring-loaded bar traps", + "Category:Petroleum traps", + "Category:Rodent bait stations", + "Category:Tar baby", + "Category:Trapdoors", + "Category:Traps in art", + "Category:Traps in heraldry", + "Category:Traps of carnivorous plants", + "Category:Videos of mice in mousetraps", + "Category:Wolfsangel" + ], + "prune_patterns": [ + " in art", + " in heraldry" + ], + "_prune_comment": "Any subcategory whose title contains one of these is pruned wholesale. Measured: Category:Mousetraps in art put a Robert Campin altarpiece panel and an 18th-century oil of a street vendor into the pool, and both arrive with no caption to explain why a painting is the answer to !pulapka. A pattern rather than a list because Commons mints a new ' in art' category whenever somebody needs one." +}