9.4 KiB
Amateurfunk — BNetzA Question Catalog → Anki Decks
A small two-stage Python pipeline that downloads the German amateur-radio exam question catalog ("Fragenkatalog") published by the Bundesnetzagentur (BNetzA) and turns it into Anki decks.
The full source-discovery notes, JSON schema, exam-structure details, and
per-stage design decisions live in DESIGN.md. This file is a short
orientation for anyone (human or agent) opening the project.
What the catalog is
- Official German amateur-radio exam questions for classes N, E, A (German license tiers).
- Published by the Bundesnetzagentur under the DL-DE→BY-2.0 open data license (free reuse, attribution required).
- Distributed as a single ZIP containing one JSON file with the full
question tree, plus a
svgs/folder with figures referenced by individual questions. - Current edition at time of writing: 3. Auflage, März 2024 (issued 2024-03-20, valid from 2024-06-24, ~1750 questions).
Canonical source
- ZIP (machine-readable):
https://www.bundesnetzagentur.de/SharedDocs/Downloads/DE/Sachgebiete/Telekommunikation/Unternehmen_Institutionen/Frequenzen/Amateurfunk/Fragenkatalog/PruefungsfragenZIP.zip?__blob=publicationFile - Landing page (short link):
https://www.bnetza.de/amateurfunk-fragenkatalog - PDF (human-readable, not used by this pipeline): same path with
Pruefungsfragen.pdfinstead ofPruefungsfragenZIP.zip.
The ZIP URL is stable across editions — BNetzA replaces the file
in-place. The Last-Modified HTTP header is reliable for change
detection. The filename inside the ZIP (fragenkatalog3b.json) encodes
the edition (3b = 3rd edition, revision b) and will change on new
editions, so we discover it from the archive rather than hard-coding.
Pipeline overview
BNetzA ZIP ──[Stage 1: amateurfunk_fetch.py]──► data/<slug>/
├── fragenkatalog*.json
├── svgs/
├── README.txt
└── manifest.json
data/ ──[Stage 2: amateurfunk_anki.py]──► anki/
├── amateurfunk-technische-kenntnisse-n.apkg
├── amateurfunk-technische-kenntnisse-e.apkg (one file, 11 topic sub-decks)
├── amateurfunk-technische-kenntnisse-a.apkg (one file, 11 topic sub-decks)
├── amateurfunk-betriebliche-kenntnisse.apkg
└── amateurfunk-kenntnisse-von-vorschriften.apkg
shorthand.json ──[Stage 2b: amateurfunk_shorthand.py]──► anki/
└── amateurfunk-abkuerzungen-q-gruppen.apkg
technical.json ──[Stage 2c: amateurfunk_technical.py]──► anki/
└── amateurfunk-technische-abkuerzungen.apkg
Stage 1 — amateurfunk_fetch.py
- Download the ZIP from the canonical URL.
- Verify it is a valid ZIP and contains the expected JSON + SVG files.
- Extract to a target directory (default:
./data/<edition>/). - Emit a small
manifest.jsonnext to the data: source URL, fetched-at timestamp,Last-Modifiedfrom the server, JSON edition metadata, sha256 of the ZIP. - Be idempotent — re-running without an upstream change is a no-op.
The skip key is the HTTP
Last-Modifiedheader recorded on the previous manifest; the ZIP is deleted by default after extraction, so the recorded sha256 is provenance, not a re-verification target. SeeDESIGN.md§4 for the full idempotency contract.
Stage 2 — amateurfunk_anki.py
- Read the latest edition from
data/(followingmanifest-latest.jsonto a per-edition directory). - Split the catalog into five categories. Betriebliche and
Vorschriften get one deck each (shared across every candidate).
Technische is additionally fanned out per license class using a
strict equality split on the question's
classfield: one.apkgeach for N, E, and A. The E (463) and A (716) packages — the large pools — are each built as a deck tree: one sub-deck per first-level catalog topic (the 11 subsections) under an anchoringTechnische Kenntnisse::E/::Aparent, so each imports as a single file but studies topic by topic. N stays a single flat deck. The set of split classes isTOPIC_SPLIT_CLASSES. Theklasse-N|E|Atag is still emitted on every note for inside-Anki filtering. - Render every question as an Anki note: shuffled A/B/C/D choices on
the front, the displayed position of the correct answer on the
back. Inline
$...$LaTeX is converted to MathJax\(...\)delimiters; the catalog's safe inline markup (<u>...</u>) is preserved. If the question's number has an entry inexplanations.json(see EXPLANATIONS.md), an English explanation block is appended to the back; a "low confidence" badge shows for entries withconfidence < 7. - Hand-roll the v11 Anki collection (SQLite + JSON config) and
package it as a
.apkgZIP with deterministic timestamps. By default each build mints a fresh shuffle seed, so answers are reshuffled every run. Pass--seed(and--epoch) for the reproducible-build contract: same catalog + same seed + same timestamp → byte-identical output across runs.
The Anki design decisions (shuffle seeding, deterministic build epoch,
SVG dark-mode handling, schema choices) live in DESIGN.md §7.
Stage 2b — amateurfunk_shorthand.py
A sibling builder for a standalone reference deck of Q-groups and
operating abbreviations — the ones in the exam plus the most common
on-air shorthand the exam never covers (real operating knowledge, not
just the test). Content lives in the hand-curated shorthand.json
(editorial, tracked in git, like explanations.json); the
references/Q-Codes.md reference is where the exam-present codes were
catalogued.
Each code is one Anki note with two card templates — forward
(code → meaning) and reverse (meaning → code) — so a single record
drives both directions. A Q-group means one thing as a statement
(QSO) and another as a question (QSO?), so each Q-group yields two
notes; plain abbreviations yield one. All IDs/GUIDs are hashed from the
displayed code form (stable re-import). The deck is catalog-independent
and fully deterministic; it only consults data/ to borrow the
manifest build epoch when present. Low-level apkg/SQLite machinery is
imported from amateurfunk_anki.py so the two stay in lockstep. The
glossary machinery shared with Stage 2c (two-template note type,
two-cards-per-note writer, packager, build-epoch resolver, entry
validator) also lives here.
Stage 2c — amateurfunk_technical.py
A third glossary deck, same card mechanics as Stage 2b (one note,
forward+reverse templates), for the technical vocabulary rather than
operating shorthand: modulation/modes (SSB, FM, CW), signal domains
(NF, HF, ZF), building blocks (VFO, PLL, AGC), components, measurements
(dB, SWR, PEP), propagation, digital modes, and the
organisations/regulations (ITU, CEPT, EMV) — exam terms plus common HAM
abbreviations beyond the exam. Content lives in the curated
technical.json; each entry carries a German category (Betriebsart,
Bauteil, …) shown on the card and used as a kategorie-* tag. The
shared glossary machinery is imported from amateurfunk_shorthand.py;
this script only adds the data shape, the deck/model names, and the tag
scheme. IDs live in their own technical namespace so the two glossary
decks never collide on import.
Repo conventions
- Python 3.11+, standard library only. No third-party dependencies in either stage.
- Single-file scripts:
amateurfunk_fetch.py,amateurfunk_anki.py. No frameworks, no CLI library beyondargparse. - Style: section banners, commented constants, docstrings on every function, inline comments at decision points. The two scripts intentionally read the same way.
- Outputs are build artifacts: kept under
data/andanki/, both gitignored. - License attribution string (required by DL-DE→BY-2.0) is preserved
verbatim from the upstream
README.txtwhenever we redistribute the data.
Working on this repo
EXPLANATIONS.md— the editorial contract for agents asked to add or improve per-question explanations. The schema, the workflows ("explain everything unexplained", "improve everything below confidence 7"), and the source/confidence guidance live there.explanations.jsonis an empty{}until agents populate it.- Start from
DESIGN.md— it has the JSON schema, the question/answer conventions (answer A is always correct upstream → consumers shuffle before display), the LaTeX-in-questions caveat, the exam-structure rationale for the Anki package layout (Betriebliche, Vorschriften, and Technische split per license class), and per-stage design notes. - Do not invent new download URLs; the ones in
DESIGN.mdwere verified against the live BNetzA site. - When BNetzA publishes a new edition, expect a new
fragenkatalog<N><rev>.jsonfilename inside the ZIP. The fetcher must not hard-code the current name. - Both stages have a fixture-driven test suite. Run with
python3 -m unittest test_amateurfunk_fetch test_amateurfunk_anki. Network access is only needed for the manual smoke test of Stage 1.