Files
ubuntu-post-install/tools/anki-deck-periodic.py
T
Claude 2070c31cfe anki-deck-periodic.py: fix element-photo fetching hitting Wikipedia's rate limit
Confirmed live by the user: the one-request-per-element loop (per-element
API metadata lookup + per-element image download, no delay between any of
them) got 429'd by Wikimedia partway through a real run of --deck hs.

Fixes:
- Batch pageimage metadata lookups up to 50 titles per MediaWiki query
  instead of one request per element (ensure_element_photos ->
  resolve_pageimage_urls), cutting ~99 requests down to ~2 for a full hs
  run.
- Retry with backoff on 429 (http_get_with_retry), honoring Wikipedia's
  own Retry-After header when present.
- Request thumbnails (piprop=thumbnail) instead of full-resolution
  originals, per Wikimedia's own guidance in the 429 response body.
- Small delay between individual image downloads, which still can't be
  batched (one HTTP request per element's actual image bytes).

Also fixes a real bug caught while unit-testing the new batched
redirect/normalization resolution against a simulated response: the
final-title -> original-input-title lookup had the mapping backwards
(looked up by final title in a dict keyed by input title), which would
have silently dropped every element whose title needed resolving through
a redirect (e.g. Cesium -> Caesium) even after the rate-limit fix.

Still couldn't test against the real Wikipedia API (no network route to
en.wikipedia.org from this sandbox) — verified instead with a local HTTP
server simulating 429-then-200 and a fabricated MediaWiki response with a
redirect chain. Needs a real run to fully confirm.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014DyceEVVeQ33EeS6C1PDv5
2026-09-10 16:55:57 +00:00

604 lines
25 KiB
Python

#!/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": """
<div class="prompt">{{Prompt}}</div>
{{QSound}}
{{type:Answer}}
""",
"afmt": """
<div class="prompt">{{Prompt}}</div>
<hr id="answer">
{{type:Answer}}
{{ASound}}
""",
}],
css="""
.card { font-family: Arial, sans-serif; font-size: 26px; text-align: center; }
.prompt { font-size: 40px; margin: 20px auto; white-space: pre-line; }
.elem-img { max-width: 220px; max-height: 220px; display: block; margin: 0 auto 10px; }
""",
)
def build_deck(deck_key, deck_title):
voice_hash = int(hashlib.sha256(f"{deck_key}:{args.voice}".encode()).hexdigest(), 16)
deck_id = 2_100_000_000 + (voice_hash % 90_000_000)
return genanki.Deck(deck_id, deck_title)
def element_image_html(number, symbol, media_files):
"""Returns an <img> tag for this element's cached photo, or "" if
--no-images was passed, the element is in NO_PHOTO_NUMBERS, or no
photo was found/downloaded for it. Reads the cache only — every
network call happens up front in ensure_element_photos(), once per
deck, not per card."""
if args.no_images or number in NO_PHOTO_NUMBERS:
return ""
cache_path = os.path.join(IMAGE_CACHE, f"{symbol}.jpg")
if not os.path.isfile(cache_path):
return ""
if cache_path not in media_files:
media_files.append(cache_path)
return f'<img class="elem-img" src="{os.path.basename(cache_path)}">'
def add_note(deck, model, prompt, answer, qtext, atext, media_files, tag):
qfile = f"q_{tag}.wav"
afile = f"a_{tag}.wav"
qpath = os.path.join(MEDIA_DIR, qfile)
apath = os.path.join(MEDIA_DIR, afile)
piper_tts(qtext, qpath)
piper_tts(atext, apath)
media_files += [qpath, apath]
deck.add_note(genanki.Note(
model=model,
fields=[prompt, answer, f"[sound:{qfile}]", f"[sound:{afile}]"],
))
def gen_prehs():
deck_key = "periodic_prehs"
model_id, model = build_model(deck_key)
deck = build_deck(deck_key, "Periodic Table: Symbols & Names (1-36)")
media_files = []
subset = [e for e in ELEMENTS if e[0] <= 36]
ensure_element_photos(subset)
cards = []
for number, symbol, name, category in subset:
cards.append(("symbol_to_name", number, symbol, name))
cards.append(("name_to_symbol", number, symbol, name))
random.seed(50)
random.shuffle(cards)
for kind, number, symbol, name in cards:
img = element_image_html(number, symbol, media_files)
if kind == "symbol_to_name":
add_note(deck, model, img + symbol, name,
f"What element has the symbol {symbol}?", name,
media_files, f"prehs_s2n_{number}")
else:
add_note(deck, model, img + name, symbol,
f"What is the symbol for {name}?", symbol,
media_files, f"prehs_n2s_{number}")
return deck, media_files, len(cards)
def gen_hs():
deck_key = "periodic_hs"
model_id, model = build_model(deck_key)
deck = build_deck(deck_key, "Periodic Table: Symbols, Names & Numbers (1-118)")
media_files = []
ensure_element_photos(ELEMENTS)
cards = []
for number, symbol, name, category in ELEMENTS:
cards.append(("symbol_to_name", number, symbol, name))
cards.append(("name_to_symbol", number, symbol, name))
cards.append(("number_to_symbol", number, symbol, name))
random.seed(51)
random.shuffle(cards)
for kind, number, symbol, name in cards:
img = element_image_html(number, symbol, media_files)
if kind == "symbol_to_name":
add_note(deck, model, img + symbol, name,
f"What element has the symbol {symbol}?", name,
media_files, f"hs_s2n_{number}")
elif kind == "name_to_symbol":
add_note(deck, model, img + name, symbol,
f"What is the symbol for {name}?", symbol,
media_files, f"hs_n2s_{number}")
else:
add_note(deck, model, img + f"Element #{number}", symbol,
f"What is the symbol for element number {num2words(number)}?", symbol,
media_files, f"hs_num2s_{number}")
return deck, media_files, len(cards)
def gen_category():
deck_key = "periodic_category"
model_id, model = build_model(deck_key)
deck = build_deck(deck_key, "Periodic Table: Element Categories (multiple choice)")
media_files = []
subset = [e for e in ELEMENTS if e[3] is not None]
random.seed(52)
shuffled = subset[:]
random.shuffle(shuffled)
letters = ["A", "B", "C", "D"]
for number, symbol, name, category in shuffled:
distractor_pool = [c for c in ALL_CATEGORIES if c != category]
distractors = random.sample(distractor_pool, 3)
choices = distractors + [category]
random.shuffle(choices)
correct_letter = letters[choices.index(category)]
prompt_lines = [f"{name} ({symbol})", ""]
for letter, choice in zip(letters, choices):
prompt_lines.append(f"{letter}) {choice}")
prompt = "\n".join(prompt_lines)
qtext = f"What category is {name}?"
atext = f"{category}"
add_note(deck, model, prompt, correct_letter, qtext, atext,
media_files, f"cat_{number}")
return deck, media_files, len(subset)
if args.deck == "prehs":
deck_key = "periodic_prehs"
elif args.deck == "hs":
deck_key = "periodic_hs"
else:
deck_key = "periodic_category"
MEDIA_DIR = os.path.join(SCRATCH, f"media_{deck_key}_{args.voice}")
os.makedirs(MEDIA_DIR, exist_ok=True)
GENERATORS = {"prehs": gen_prehs, "hs": gen_hs, "category": gen_category}
deck, media_files, count = GENERATORS[args.deck]()
package = genanki.Package(deck)
package.media_files = media_files
out_path = os.path.join(SCRATCH, f"{deck_key}_{args.voice}.apkg")
package.write_to_file(out_path)
size_mb = os.path.getsize(out_path) / (1024 * 1024)
print(f"\nDone: {out_path} ({size_mb:.1f} MB, {count} cards, {len(media_files)} media files)")