#!/usr/bin/env python3 """tools/anki-deck-periodic.py — Generate periodic table Anki decks (.apkg) with Anki's built-in type-the-answer input and Piper (offline, local neural TTS) audio on both sides. See tools/anki-deck-math.py's docstring for one-time setup (venv, genanki + piper-tts, downloading a voice) — same steps apply here, this is a standalone, self-contained script otherwise. Decks: prehs symbol<->name, elements 1-36 (H through Kr) hs symbol<->name plus number->symbol, all 118 elements category element category as multiple choice (A/B/C/D shown as plain text options — not a clickable UI, since that needs a desktop-only Anki add-on and would break on AnkiDroid/AnkiMobile), type the letter — only elements with a confirmed category (excludes 8 very recent superheavy elements whose category is still officially unconfirmed) Element photos on prehs/hs: every card (symbol->name, name->symbol, number->symbol alike) also shows a real photo of the element, fetched once from Wikipedia's own MediaWiki API (the same "pageimage" shown in that element's infobox — the well-documented, standard `action=query&prop=pageimages` endpoint on en.wikipedia.org, not a guessed URL) and cached locally under tools/periodic_images/ so reruns don't re-download. This is the one part of this repo's Anki tooling that needs internet access at generation time — every other deck (math, shapes/clocks/currency, TTS audio) is fully offline. Pass --no-images to skip this and get the old text-only prehs/hs cards back. Elements 100 (Fermium) through 118 (Oganesson) are hard-excluded from this — every atom of these ever made has been produced (or claimed) one at a time in a particle accelerator and never existed in macroscopic, visible quantity, so no real sample photo exists to fetch; anything Wikipedia's pageimage API returned for them would be a diagram or a scientist's portrait, not the element. A couple of element names collide with a more famous Wikipedia topic under the same plain title (Mercury the planet, for one) — TITLE_OVERRIDES below is the fix-up list; if a generated card shows an obviously wrong photo for some element, that's almost certainly another one of these collisions — add it there. Batched and rate-limited on purpose: metadata lookups (which element has which photo) go out up to 50 titles per request, not one request per element, and every HTTP call retries with backoff on a 429 (honoring Wikipedia's own Retry-After header when it sends one). The first version of this fetched one element at a time with no delay between requests and got rate-limited by Wikimedia on a real run — this replaced it. Caveat: this was written and tested without live access to Wikipedia (sandboxed here with no route to en.wikipedia.org) — exercised structurally (see --dry-run-tts), the redirect/normalization-chain resolution logic and the 429-retry path both unit-tested against simulated responses, but never against the real API's actual response shape or real image content. Skips are per-element and non-fatal — one bad/missing photo won't abort the rest of the deck — and every fetch attempt prints what it did, so check that output the first time you actually run this deck for real. Element data: Bowserinator/Periodic-Table-JSON (a widely used, actively maintained public dataset), fetched and spot-checked against known facts before being embedded below — not typed from memory. Usage (run with the venv from anki-deck-math.py's docstring activated; also needs `pip install pillow` for resizing fetched photos): python3 anki-deck-periodic.py --deck prehs python3 anki-deck-periodic.py --deck hs python3 anki-deck-periodic.py --deck hs --no-images python3 anki-deck-periodic.py --deck category (add --voice en_US-amy-medium etc.; --model-path if a voice isn't found automatically; --dry-run-tts to test the deck-building logic without any voice model OR network access at all, using silent placeholder audio and a placeholder image) """ import argparse import hashlib import io import genanki import json import os import random import subprocess import time import urllib.error import urllib.parse import urllib.request from PIL import Image parser = argparse.ArgumentParser() parser.add_argument("--deck", required=True, choices=["prehs", "hs", "category"]) parser.add_argument("--voice", default="en_US-lessac-medium") parser.add_argument("--model-path", default=None) parser.add_argument("--dry-run-tts", action="store_true") parser.add_argument("--no-images", action="store_true", help="prehs/hs only: skip fetching element photos, keep the old text-only cards") args = parser.parse_args() SCRATCH = os.path.dirname(os.path.abspath(__file__)) IMAGE_CACHE = os.path.join(SCRATCH, "periodic_images") os.makedirs(IMAGE_CACHE, exist_ok=True) # Elements with no macroscopic sample ever produced — see the docstring. NO_PHOTO_NUMBERS = set(range(100, 119)) # Element names whose plain Wikipedia article title is a different, more # famous topic — see the docstring. Add to this if a generated card shows # an obviously wrong photo for some element. TITLE_OVERRIDES = {"Hg": "Mercury (element)"} USER_AGENT = "anki-deck-periodic.py/1.0 (personal Anki deck generator, run locally by its owner)" def http_get_with_retry(url, max_retries=5): """GET with retry-on-429: honors a numeric Retry-After header if Wikipedia sends one, otherwise backs off 5s * attempt. Returns the raw response bytes, or None if every attempt failed — never raises, since one element's fetch failing must not abort the whole deck build.""" headers = {"User-Agent": USER_AGENT} for attempt in range(1, max_retries + 1): try: req = urllib.request.Request(url, headers=headers) with urllib.request.urlopen(req, timeout=20) as resp: return resp.read() except urllib.error.HTTPError as e: if e.code == 429 and attempt < max_retries: wait = 5 * attempt retry_after = e.headers.get("Retry-After") if e.headers else None if retry_after and retry_after.isdigit(): wait = int(retry_after) print(f" (rate limited, waiting {wait}s before retry {attempt}/{max_retries})") time.sleep(wait) continue print(f" (request failed: {e})") return None except (urllib.error.URLError, OSError, ValueError) as e: print(f" (request failed: {e})") return None return None def resolve_pageimage_urls(pairs): """pairs: list of (symbol, wikipedia_title). Batches lookups — up to 50 titles per MediaWiki query, the documented anonymous-access limit — instead of one API call per element; a tight one-request-per-element loop is exactly what triggered Wikipedia's rate limiting on a real run. Requests thumbnails (piprop=thumbnail), not full-resolution originals, per Wikimedia's own guidance on their 429 response. Returns {symbol: thumbnail_url} for whichever elements actually have one.""" result = {} CHUNK = 50 for i in range(0, len(pairs), CHUNK): chunk = pairs[i:i + CHUNK] symbol_by_title = {title: symbol for symbol, title in chunk} titles_param = "|".join(title for _, title in chunk) api_url = ("https://en.wikipedia.org/w/api.php?action=query&format=json" "&prop=pageimages&piprop=thumbnail&pithumbsize=300&redirects=1&titles=" + urllib.parse.quote(titles_param)) raw = http_get_with_retry(api_url) if raw is None: continue data = json.loads(raw) query = data.get("query", {}) # "pages" below is keyed by pageid with only the *final* resolved # title on it, so build a title-at-this-point -> original-input- # title map and walk it forward through each normalized/redirect # step, re-keying as the title changes, to reach the same result. input_of_title = {title: title for _, title in chunk} for step in query.get("normalized", []) + query.get("redirects", []): frm, to = step["from"], step["to"] if frm in input_of_title: input_of_title[to] = input_of_title.pop(frm) for page in query.get("pages", {}).values(): final_title = page.get("title") input_title = input_of_title.get(final_title, final_title) symbol = symbol_by_title.get(input_title) if symbol is None: continue thumb_url = page.get("thumbnail", {}).get("source") if thumb_url: result[symbol] = thumb_url if i + CHUNK < len(pairs): time.sleep(1) # be polite between batches return result def download_element_photos(url_map): """url_map: {symbol: thumbnail_url}. Downloads each into periodic_images/, one request at a time with a short gap between — the metadata lookups above are batched, but the actual image bytes still need one HTTP request per element, and Wikimedia's upload servers rate-limit that too if hit back-to-back with no gap.""" for symbol, url in url_map.items(): cache_path = os.path.join(IMAGE_CACHE, f"{symbol}.jpg") if os.path.isfile(cache_path): continue raw = http_get_with_retry(url) if raw is None: print(f" (photo download failed for {symbol})") continue try: img = Image.open(io.BytesIO(raw)).convert("RGB") img.thumbnail((300, 300)) img.save(cache_path, "JPEG", quality=85) print(f" fetched photo for {symbol}") except OSError as e: print(f" (photo decode failed for {symbol}: {e})") time.sleep(0.5) def ensure_element_photos(elements): """Call once per deck, before building any notes: makes sure every eligible element (not --no-images, not NO_PHOTO_NUMBERS, not already cached) has its photo downloaded into periodic_images/ up front — batched and rate-limited, rather than the old one-request-per-card approach that got 429'd on a real run. element_image_html() below then only ever reads the cache; it makes no network calls itself.""" if args.no_images: return needed = [(symbol, name) for number, symbol, name, _cat in elements if number not in NO_PHOTO_NUMBERS and not os.path.isfile(os.path.join(IMAGE_CACHE, f"{symbol}.jpg"))] if not needed: return if args.dry_run_tts: # No real network call in dry-run mode — a flat placeholder lets the # rest of the pipeline (HTML wiring, media_files list, .apkg # packaging) still be exercised end-to-end without it. for symbol, _name in needed: cache_path = os.path.join(IMAGE_CACHE, f"{symbol}.jpg") Image.new("RGB", (100, 100), (190, 190, 190)).save(cache_path, "JPEG") return pairs = [(symbol, TITLE_OVERRIDES.get(symbol, name)) for symbol, name in needed] print(f"Fetching {len(pairs)} element photo(s) from Wikipedia (batched, rate-limited)...") url_map = resolve_pageimage_urls(pairs) for symbol, _title in pairs: if symbol not in url_map: print(f" (no photo found for {symbol})") download_element_photos(url_map) _CANDIDATES = [ args.model_path, f"{args.voice}.onnx", os.path.join(SCRATCH, f"{args.voice}.onnx"), os.path.expanduser(f"~/{args.voice}.onnx"), os.path.expanduser(f"~/.local/share/piper/voices/{args.voice}.onnx"), ] VOICE_MODEL = next((p for p in _CANDIDATES if p and os.path.isfile(p)), None) if VOICE_MODEL is None and not args.dry_run_tts: raise SystemExit( f"Voice model for '{args.voice}' not found. Checked:\n" + "\n".join(f" {p}" for p in _CANDIDATES if p) + f"\n\nFind it with: find / -iname '{args.voice}.onnx' 2>/dev/null" + "\nThen pass its exact path with --model-path /the/real/path.onnx" + "\n(or pass --dry-run-tts to test deck-building without any voice at all)" ) def piper_tts(text: str, out_path: str) -> None: if args.dry_run_tts: with open(out_path, "wb") as f: f.write( b"RIFF$\x00\x00\x00WAVEfmt \x10\x00\x00\x00\x01\x00\x01\x00" b"\x22\x56\x00\x00\x44\xac\x00\x00\x02\x00\x10\x00data\x00\x00\x00\x00" ) return subprocess.run( ["python3", "-m", "piper", "-m", VOICE_MODEL, "-f", out_path], input=text.encode("utf-8"), check=True, capture_output=True, ) ONES = ["zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen", "seventeen", "eighteen", "nineteen"] TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety"] def num2words(n): if n < 20: return ONES[n] if n < 100: t, o = divmod(n, 10) return TENS[t] + ("-" + ONES[o] if o else "") h, rem = divmod(n, 100) return ONES[h] + " hundred" + (" " + num2words(rem) if rem else "") assert num2words(1) == "one" assert num2words(26) == "twenty-six" assert num2words(118) == "one hundred eighteen" # ─── Element data: (atomic_number, symbol, name, category-or-None) ───────── # category is None for the 8 most recently synthesized superheavy elements # whose chemical category is still officially unconfirmed (excluded from # the category deck below, still included in prehs/hs symbol/name/number). ELEMENTS = [ (1, 'H', 'Hydrogen', 'diatomic nonmetal'), (2, 'He', 'Helium', 'noble gas'), (3, 'Li', 'Lithium', 'alkali metal'), (4, 'Be', 'Beryllium', 'alkaline earth metal'), (5, 'B', 'Boron', 'metalloid'), (6, 'C', 'Carbon', 'polyatomic nonmetal'), (7, 'N', 'Nitrogen', 'diatomic nonmetal'), (8, 'O', 'Oxygen', 'diatomic nonmetal'), (9, 'F', 'Fluorine', 'diatomic nonmetal'), (10, 'Ne', 'Neon', 'noble gas'), (11, 'Na', 'Sodium', 'alkali metal'), (12, 'Mg', 'Magnesium', 'alkaline earth metal'), (13, 'Al', 'Aluminium', 'post-transition metal'), (14, 'Si', 'Silicon', 'metalloid'), (15, 'P', 'Phosphorus', 'polyatomic nonmetal'), (16, 'S', 'Sulfur', 'polyatomic nonmetal'), (17, 'Cl', 'Chlorine', 'diatomic nonmetal'), (18, 'Ar', 'Argon', 'noble gas'), (19, 'K', 'Potassium', 'alkali metal'), (20, 'Ca', 'Calcium', 'alkaline earth metal'), (21, 'Sc', 'Scandium', 'transition metal'), (22, 'Ti', 'Titanium', 'transition metal'), (23, 'V', 'Vanadium', 'transition metal'), (24, 'Cr', 'Chromium', 'transition metal'), (25, 'Mn', 'Manganese', 'transition metal'), (26, 'Fe', 'Iron', 'transition metal'), (27, 'Co', 'Cobalt', 'transition metal'), (28, 'Ni', 'Nickel', 'transition metal'), (29, 'Cu', 'Copper', 'transition metal'), (30, 'Zn', 'Zinc', 'transition metal'), (31, 'Ga', 'Gallium', 'post-transition metal'), (32, 'Ge', 'Germanium', 'metalloid'), (33, 'As', 'Arsenic', 'metalloid'), (34, 'Se', 'Selenium', 'polyatomic nonmetal'), (35, 'Br', 'Bromine', 'diatomic nonmetal'), (36, 'Kr', 'Krypton', 'noble gas'), (37, 'Rb', 'Rubidium', 'alkali metal'), (38, 'Sr', 'Strontium', 'alkaline earth metal'), (39, 'Y', 'Yttrium', 'transition metal'), (40, 'Zr', 'Zirconium', 'transition metal'), (41, 'Nb', 'Niobium', 'transition metal'), (42, 'Mo', 'Molybdenum', 'transition metal'), (43, 'Tc', 'Technetium', 'transition metal'), (44, 'Ru', 'Ruthenium', 'transition metal'), (45, 'Rh', 'Rhodium', 'transition metal'), (46, 'Pd', 'Palladium', 'transition metal'), (47, 'Ag', 'Silver', 'transition metal'), (48, 'Cd', 'Cadmium', 'transition metal'), (49, 'In', 'Indium', 'post-transition metal'), (50, 'Sn', 'Tin', 'post-transition metal'), (51, 'Sb', 'Antimony', 'metalloid'), (52, 'Te', 'Tellurium', 'metalloid'), (53, 'I', 'Iodine', 'diatomic nonmetal'), (54, 'Xe', 'Xenon', 'noble gas'), (55, 'Cs', 'Cesium', 'alkali metal'), (56, 'Ba', 'Barium', 'alkaline earth metal'), (57, 'La', 'Lanthanum', 'lanthanide'), (58, 'Ce', 'Cerium', 'lanthanide'), (59, 'Pr', 'Praseodymium', 'lanthanide'), (60, 'Nd', 'Neodymium', 'lanthanide'), (61, 'Pm', 'Promethium', 'lanthanide'), (62, 'Sm', 'Samarium', 'lanthanide'), (63, 'Eu', 'Europium', 'lanthanide'), (64, 'Gd', 'Gadolinium', 'lanthanide'), (65, 'Tb', 'Terbium', 'lanthanide'), (66, 'Dy', 'Dysprosium', 'lanthanide'), (67, 'Ho', 'Holmium', 'lanthanide'), (68, 'Er', 'Erbium', 'lanthanide'), (69, 'Tm', 'Thulium', 'lanthanide'), (70, 'Yb', 'Ytterbium', 'lanthanide'), (71, 'Lu', 'Lutetium', 'lanthanide'), (72, 'Hf', 'Hafnium', 'transition metal'), (73, 'Ta', 'Tantalum', 'transition metal'), (74, 'W', 'Tungsten', 'transition metal'), (75, 'Re', 'Rhenium', 'transition metal'), (76, 'Os', 'Osmium', 'transition metal'), (77, 'Ir', 'Iridium', 'transition metal'), (78, 'Pt', 'Platinum', 'transition metal'), (79, 'Au', 'Gold', 'transition metal'), (80, 'Hg', 'Mercury', 'transition metal'), (81, 'Tl', 'Thallium', 'post-transition metal'), (82, 'Pb', 'Lead', 'post-transition metal'), (83, 'Bi', 'Bismuth', 'post-transition metal'), (84, 'Po', 'Polonium', 'post-transition metal'), (85, 'At', 'Astatine', 'diatomic nonmetal'), (86, 'Rn', 'Radon', 'noble gas'), (87, 'Fr', 'Francium', 'alkali metal'), (88, 'Ra', 'Radium', 'alkaline earth metal'), (89, 'Ac', 'Actinium', 'actinide'), (90, 'Th', 'Thorium', 'actinide'), (91, 'Pa', 'Protactinium', 'actinide'), (92, 'U', 'Uranium', 'actinide'), (93, 'Np', 'Neptunium', 'actinide'), (94, 'Pu', 'Plutonium', 'actinide'), (95, 'Am', 'Americium', 'actinide'), (96, 'Cm', 'Curium', 'actinide'), (97, 'Bk', 'Berkelium', 'actinide'), (98, 'Cf', 'Californium', 'actinide'), (99, 'Es', 'Einsteinium', 'actinide'), (100, 'Fm', 'Fermium', 'actinide'), (101, 'Md', 'Mendelevium', 'actinide'), (102, 'No', 'Nobelium', 'actinide'), (103, 'Lr', 'Lawrencium', 'actinide'), (104, 'Rf', 'Rutherfordium', 'transition metal'), (105, 'Db', 'Dubnium', 'transition metal'), (106, 'Sg', 'Seaborgium', 'transition metal'), (107, 'Bh', 'Bohrium', 'transition metal'), (108, 'Hs', 'Hassium', 'transition metal'), (109, 'Mt', 'Meitnerium', None), (110, 'Ds', 'Darmstadtium', None), (111, 'Rg', 'Roentgenium', None), (112, 'Cn', 'Copernicium', None), (113, 'Nh', 'Nihonium', 'post-transition metal'), (114, 'Fl', 'Flerovium', 'post-transition metal'), (115, 'Mc', 'Moscovium', None), (116, 'Lv', 'Livermorium', None), (117, 'Ts', 'Tennessine', None), (118, 'Og', 'Oganesson', None), ] assert len(ELEMENTS) == 118 assert [e[0] for e in ELEMENTS] == list(range(1, 119)) assert ELEMENTS[0] == (1, 'H', 'Hydrogen', 'diatomic nonmetal') assert ELEMENTS[25] == (26, 'Fe', 'Iron', 'transition metal') assert ELEMENTS[-1] == (118, 'Og', 'Oganesson', None) ALL_CATEGORIES = sorted({e[3] for e in ELEMENTS if e[3] is not None}) def build_model(deck_key): voice_hash = int(hashlib.sha256(f"{deck_key}:{args.voice}".encode()).hexdigest(), 16) model_id = 1_700_000_000 + (voice_hash % 90_000_000) return model_id, genanki.Model( model_id, f"Periodic Table ({deck_key}, {args.voice})", fields=[{"name": "Prompt"}, {"name": "Answer"}, {"name": "QSound"}, {"name": "ASound"}], templates=[{ "name": "Card", "qfmt": """