This commit is contained in:
2026-05-20 18:18:53 +02:00
parent 58af96d7f5
commit dd61be73f3
5 changed files with 862 additions and 310 deletions
+62 -26
View File
@@ -1,13 +1,12 @@
# Amateurfunk — BNetzA Question Catalog Downloader # Amateurfunk — BNetzA Question Catalog → Anki Decks
A small Python tool that downloads the latest German amateur-radio exam A small two-stage Python pipeline that downloads the German amateur-radio
question catalog ("Fragenkatalog") published by the Bundesnetzagentur exam question catalog ("Fragenkatalog") published by the Bundesnetzagentur
(BNetzA) and extracts the structured JSON + SVG image assets for further (BNetzA) and turns it into Anki decks.
use (study apps, flashcards, training tools, etc.).
The full source-discovery notes, JSON schema, and design decisions live The full source-discovery notes, JSON schema, exam-structure details, and
in `DESIGN.md`. This file is a short orientation for anyone (human or per-stage design decisions live in `DESIGN.md`. This file is a short
agent) opening the project. orientation for anyone (human or agent) opening the project.
## What the catalog is ## What the catalog is
@@ -25,7 +24,7 @@ agent) opening the project.
- ZIP (machine-readable): `https://www.bundesnetzagentur.de/SharedDocs/Downloads/DE/Sachgebiete/Telekommunikation/Unternehmen_Institutionen/Frequenzen/Amateurfunk/Fragenkatalog/PruefungsfragenZIP.zip?__blob=publicationFile` - 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` - Landing page (short link): `https://www.bnetza.de/amateurfunk-fragenkatalog`
- PDF (human-readable, not used by this tool): same path with - PDF (human-readable, not used by this pipeline): same path with
`Pruefungsfragen.pdf` instead of `PruefungsfragenZIP.zip`. `Pruefungsfragen.pdf` instead of `PruefungsfragenZIP.zip`.
The ZIP URL is stable across editions — BNetzA replaces the file The ZIP URL is stable across editions — BNetzA replaces the file
@@ -34,32 +33,65 @@ detection. The filename inside the ZIP (`fragenkatalog3b.json`) encodes
the edition (`3b` = 3rd edition, revision b) and will change on new the edition (`3b` = 3rd edition, revision b) and will change on new
editions, so we discover it from the archive rather than hard-coding. editions, so we discover it from the archive rather than hard-coding.
## Scope of the tool (initial) ## 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.apkg
├── amateurfunk-betriebliche-kenntnisse.apkg
└── amateurfunk-kenntnisse-von-vorschriften.apkg
```
### Stage 1 — `amateurfunk_fetch.py`
1. Download the ZIP from the canonical URL. 1. Download the ZIP from the canonical URL.
2. Verify it is a valid ZIP and contains the expected JSON + SVG files. 2. Verify it is a valid ZIP and contains the expected JSON + SVG files.
3. Extract to a target directory (default: `./data/<edition>/`). 3. Extract to a target directory (default: `./data/<edition>/`).
4. Emit a small `manifest.json` next to the data: source URL, fetched-at 4. Emit a small `manifest.json` next to the data: source URL,
timestamp, `Last-Modified` from the server, JSON edition metadata, fetched-at timestamp, `Last-Modified` from the server, JSON edition
sha256 of the ZIP. metadata, sha256 of the ZIP.
5. Be idempotent — re-running without an upstream change is a no-op. 5. Be idempotent — re-running without an upstream change is a no-op.
The skip key is the HTTP `Last-Modified` header recorded on the The skip key is the HTTP `Last-Modified` header recorded on the
previous manifest; the ZIP is deleted by default after extraction, previous manifest; the ZIP is deleted by default after extraction,
so the recorded sha256 is provenance, not a re-verification target. so the recorded sha256 is provenance, not a re-verification target.
See `DESIGN.md` for the full idempotency contract. See `DESIGN.md` §4 for the full idempotency contract.
Out of scope for v1 (kept for later): rendering LaTeX/SVG, building a ### Stage 2 — `amateurfunk_anki.py`
study app, multi-edition diffing, mirroring the PDF.
1. Read the latest edition from `data/` (following
`manifest-latest.json` to a per-edition directory).
2. Split the catalog into three categories — one per top-level
Prüfungsteil (Technische / Betriebliche / Vorschriften). The
license-class axis is mapped into tags, not separate packages.
3. 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.
4. Hand-roll the v11 Anki collection (SQLite + JSON config) and
package it as a `.apkg` ZIP with deterministic timestamps. Same
input → 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.
## Repo conventions ## Repo conventions
- Python 3.11+, standard library only where reasonable (`urllib`, - Python 3.11+, standard library only. No third-party dependencies
`zipfile`, `hashlib`, `json`, `pathlib`). Add deps only if they pay in either stage.
for themselves — none expected for v1. - Single-file scripts: `amateurfunk_fetch.py`, `amateurfunk_anki.py`.
- Single-purpose script: `amateurfunk_fetch.py` (or a tiny package if No frameworks, no CLI library beyond `argparse`.
it grows). No framework, no CLI library beyond `argparse`. - Style: section banners, commented constants, docstrings on every
- Downloaded data is treated as a build artifact: kept under `data/` function, inline comments at decision points. The two scripts
and gitignored. intentionally read the same way.
- Outputs are build artifacts: kept under `data/` and `anki/`, both
gitignored.
- License attribution string (required by DL-DE→BY-2.0) is preserved - License attribution string (required by DL-DE→BY-2.0) is preserved
verbatim from the upstream `README.txt` whenever we redistribute the verbatim from the upstream `README.txt` whenever we redistribute the
data. data.
@@ -67,10 +99,14 @@ study app, multi-edition diffing, mirroring the PDF.
## Working on this repo ## Working on this repo
- Start from `DESIGN.md` — it has the JSON schema, the question/answer - Start from `DESIGN.md` — it has the JSON schema, the question/answer
conventions (answer A is always correct, B/C/D are distractors), the conventions (answer A is always correct upstream → consumers shuffle
LaTeX-in-questions caveat, and the SVG naming convention. before display), the LaTeX-in-questions caveat, the exam-structure
rationale for the three Anki packages, and per-stage design notes.
- Do not invent new download URLs; the ones in `DESIGN.md` were - Do not invent new download URLs; the ones in `DESIGN.md` were
verified against the live BNetzA site. verified against the live BNetzA site.
- When BNetzA publishes a new edition, expect a new - When BNetzA publishes a new edition, expect a new
`fragenkatalog<N><rev>.json` filename inside the ZIP. The downloader `fragenkatalog<N><rev>.json` filename inside the ZIP. The fetcher
must not hard-code the current name. 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.
+220 -15
View File
@@ -1,8 +1,11 @@
# Design Notes — BNetzA Amateurfunk Question Catalog Downloader # Design Notes — BNetzA Amateurfunk Question Catalog → Anki
Status: **design only, no code yet.** This document captures the Status: **both stages implemented.** Stage 1 (`amateurfunk_fetch.py`)
source-discovery work and the proposed shape of the tool. Everything downloads and validates the upstream ZIP; Stage 2
below was verified against the live BNetzA site in May 2026. (`amateurfunk_anki.py`) turns the extracted data into Anki decks. This
document captures the source-discovery work, the JSON schema, and the
design contracts that both scripts implement. Everything below was
verified against the live BNetzA site in May 2026.
--- ---
@@ -234,7 +237,7 @@ from the data.
--- ---
## 4. Tool design (v1) ## 4. Stage 1 — Fetcher (`amateurfunk_fetch.py`)
### Goal ### Goal
@@ -430,21 +433,223 @@ These were open in the first draft and have been settled:
`missing_pictures`, do not fail). Rationale: avoid bricking the `missing_pictures`, do not fail). Rationale: avoid bricking the
tool on an upstream typo. tool on an upstream typo.
## 6. Open questions ## 6. Open questions (Stage 1)
These do not block writing v1: These do not block the Stage 1 implementation:
1. **PNG fallbacks** — the archive ships PNGs for ~a handful of 1. **PNG fallbacks** — the archive ships PNGs for ~a handful of
figures alongside the SVGs. v1 just extracts everything as-is. A figures alongside the SVGs. The fetcher just extracts everything
future renderer can prefer SVG and fall back to PNG transparently. as-is. Stage 2 prefers SVG and falls back to PNG by stem:
`MediaRegistry.resolve()` tries `<stem>.svg` first, then
`<stem>.png`. So an upstream entry that ships only a PNG (or one
whose SVG fails to render in some future consumer) still resolves.
2. **Schema drift safety** — if a future edition adds or renames 2. **Schema drift safety** — if a future edition adds or renames
fields, the validator should warn but not fail. v1 fails closed fields, the validator should warn but not fail. v1 fails closed
on missing required top-level/metadata/question keys but tolerates on missing required top-level/metadata/question keys but tolerates
extra keys. We can soften this further if BNetzA evolves the extra keys. We can soften this further if BNetzA evolves the
format. format.
3. **Packaging**v1 stays a single-file script with `argparse` 3. **Packaging**both scripts stay single-file with `argparse`
(`amateurfunk_fetch.py`). Considered and declined: a (`amateurfunk_fetch.py`, `amateurfunk_anki.py`). Considered and
`pyproject.toml` package with a console-script entry point. For a declined: a `pyproject.toml` package with console-script entry
tool that runs at most quarterly, the packaging ceremony does not points. For tools that run at most quarterly, the packaging
pay for itself. Revisit if the scope grows (e.g. importable from ceremony does not pay for itself. Revisit if the scope grows
another project, distributed on PyPI). (e.g. importable from another project, distributed on PyPI).
---
## 7. Stage 2 — Anki deck builder (`amateurfunk_anki.py`)
### Goal
Given the per-edition directory produced by Stage 1, build a set of
Anki `.apkg` files that turn every catalog question into a flash card.
Same input must produce byte-identical output across runs — this is
important so generated decks can be checksummed and cached cleanly.
### CLI shape
```
amateurfunk-anki [--data DIR] [--out DIR] [--seed STR] [--epoch INT]
```
- `--data DIR` — fetch output root (default `./data`). Must contain
`manifest-latest.json` pointing at a per-edition directory.
- `--out DIR` — destination for `.apkg` files (default `./anki`).
- `--seed STR` — deterministic seed for answer shuffling. The default
is fixed; changing it produces a different (but still deterministic)
shuffle.
- `--epoch INT` — override the package timestamp epoch. By default we
derive it from the manifest's `fetched_at`; this flag is mainly for
tests and explicit rebuilds.
Exit codes: `0` success, `1` configuration / catalog / build error.
There is no Stage-2 equivalent of the fetcher's `EXIT_BAD_STATE`
the builder has no operator-recoverable local state, just generated
output artifacts.
### Output layout
```
anki/
amateurfunk-technische-kenntnisse.apkg (~2.5 MB, 1374 cards)
amateurfunk-betriebliche-kenntnisse.apkg (~130 KB, 172 cards)
amateurfunk-kenntnisse-von-vorschriften.apkg (~75 KB, 204 cards)
```
One `.apkg` per top-level Prüfungsteil. The card counts match the
study pools from §3 axis 1: Technische = 1374 (cumulative N+E+A);
Betriebliche = 172 (shared across all candidates); Vorschriften = 204
(also shared). License class is conveyed via tags
(`klasse-N` / `klasse-E` / `klasse-A`), not separate packages —
Anki users browse decks by topic and filter by class.
### Steps
1. **Load** the latest catalog: follow `manifest-latest.json` to a
per-edition directory, read the catalog JSON, the per-edition
`manifest.json`, and index the `svgs/` folder.
2. **Categorize** the question tree into three `Category` objects,
one per top-level Prüfungsteil. Each category carries the flat
list of every question that lives anywhere under it, along with
each question's path through the section tree (used for the card
breadcrumb and the path tags).
3. **For each category, render every question** as an Anki note:
- Compute a stable per-question seed from `--seed` + question
number; shuffle the A/B/C/D choices using it. The displayed
label (A/B/C/D) is assigned post-shuffle; the back of the card
names the *displayed* position of the correct answer.
- Render the question stem and answer texts to HTML via
`text_html()`, which tokenizes inline `$...$` math and rewrites
it to MathJax `\(...\)` delimiters (Anki 2.1+ ships MathJax
built in and recognizes those delimiters; bare `$...$` would
show as source). Escapes the rest for HTML safety, preserves
line breaks as `<br>`, and preserves `<u>...</u>` tags that
the catalog uses to emphasize negation in question stems.
- Resolve picture references through a per-category
`MediaRegistry`, which records every file actually used so the
packager can include only those.
4. **Build a v11 Anki collection** as an in-memory SQLite database:
one `col` row carrying JSON config blobs (deck, model, dconf),
one `notes` row per question, one `cards` row per question.
Modern Anki understands v11 and upgrades the collection on
first open.
5. **Package** the collection plus the referenced media into a
`.apkg` ZIP. Media is addressed by sequential integer keys
(Anki's convention); a `media` JSON map at the archive root
translates those keys back to filenames.
6. **Post-process SVGs** at packaging time: inject a white
`<rect>` immediately after the opening `<svg>` tag so dark-mode
Anki users can still read the black-line BNetzA figures. The
extracted source SVGs on disk are never modified. The injection
is idempotent (marked with `data-af-white-background="1"`).
7. **Write `.apkg` atomically**: build into `<out>.tmp`, then
`os.replace` into place. Stale `.tmp` directories are
overwritten on the next deterministic build — unlike the
fetcher's `.bak`/`.tmp` siblings, the Stage-2 temp file holds
no operator-recoverable state.
### Determinism
The contract is: same catalog in → same `.apkg` bytes out.
Determinism rests on three things:
1. **Stable IDs.** `stable_id(namespace, text)` hashes a
namespaced key with SHA-1 and squashes into the standard
13-digit Anki ID range. Different namespaces
(`"deck"` / `"model"` / `"note"` / `"card"`) keep IDs from
colliding across kinds; the namespaced inputs are stable
(`f"{category.slug}:{number}"` for notes and cards).
`stable_guid(text)` produces the 20-character note GUID used
for re-import deduplication.
2. **Stable shuffle.** `randomized_answers()` builds a per-question
`random.Random` seeded from SHA-256 of
`f"{cli_seed}:{question_number}"`.
3. **Stable timestamps.** Every `now` value in the collection (the
`mod` columns, the JSON config blob timestamps) is fixed to
`build_epoch` — derived from the manifest's `fetched_at`, or
overridden via `--epoch`. ZIP member timestamps are also fixed
via `ZipInfo(name, zip_datetime(build_epoch))`. Without this
last step, the inner SQLite would be identical but the
archive's per-entry mtimes would still vary between runs.
The combined effect: two runs with the same `data/` produce
byte-identical sha256 on each `.apkg`. Verified during review.
### Rendering decisions worth knowing
- **A is always correct upstream, then shuffled.** This is the
consumer-side caveat from §3 made concrete: we set the
`correct` flag on `answer_a` before shuffling, then carry it
through. The back of the card reveals the *displayed* position
("Richtige Antwort: B"), not the source position.
- **LaTeX → MathJax.** The catalog ships ~430 fragments
containing inline `$...$` (DESIGN §3 quotes the upstream README
on this). Anki 2.1+ MathJax recognizes `\(...\)` natively;
`$...$` does not render. The rewrite happens in `text_html()`.
- **Safe inline tags.** The catalog uses `<u>...</u>` for
emphasis. Naive HTML escaping would convert those to
`&lt;u&gt;...&lt;/u&gt;` and lose the emphasis. We split on a
regex that matches exactly `<u>` and `</u>` and escape only the
non-tag pieces.
- **Slug for filenames.** `slugify()` maps German umlauts and ß
to ASCII digraphs before NFKD-normalizing the rest, then keeps
only `[a-z0-9-]`. Yields readable, sortable filenames
(`amateurfunk-technische-kenntnisse.apkg`).
- **Tags.** Each note carries `klasse-N|E|A` plus
`pfad-<slugified-section>` for every section level below the
top-level Prüfungsteil. Per-question number tags are
deliberately *not* emitted — they would create ~1750 singletons
in Anki's tag tree, and the number is already in the dedicated
`Number` field for search.
- **Breadcrumb consistency.** The visible card breadcrumb and the
stored `Path` field both go through one `display_path()`
helper, so they never drift. The boilerplate
`Prüfungsfragen im Prüfungsteil:` prefix is stripped at this
layer.
- **SVG white background.** BNetzA SVGs are transparent with
black line work. In Anki's dark mode the lines disappear into
the card background. We inject a white `<rect>` as the first
painted element when packaging the SVG into the `.apkg`. The
on-disk extracted files are left untouched.
### Testing focus
Network-free, fixture-driven. Real Anki is NOT required to
verify the output — tests open the `.apkg` ZIP directly, parse
the embedded SQLite, and check structural invariants.
- Top-level structure: one `.apkg` per Prüfungsteil; expected
filename slugs.
- Note count and field shape per category.
- Tag mapping: a class-1 question carries `klasse-N`, no
`nummer-*` tags.
- Breadcrumb rendering: both the visible HTML breadcrumb and the
stored `Path` field contain the short form; neither contains
the Prüfungsteil prefix.
- LaTeX rewrite: a fixture question with `$...$` produces
`\(...\)` in the rendered HTML; a non-math `<` is still
escaped to `&lt;`; safe inline `<u>...</u>` tags survive
escaping.
- Answer shuffle: the front HTML contains the answer texts in a
non-A-first order; the back names the displayed letter of the
correct answer.
- Picture handling on shuffled answers: a fixture question whose
`picture_a`/`picture_b`/... fields point at different files
carries the right image with the right shuffled choice.
- Media inclusion: `MediaRegistry.used_paths` is populated by
resolution and only those files end up in the `.apkg`.
- SVG white background injection: idempotent; the marker rect is
emitted exactly once.
- Deterministic build: two consecutive builds against the same
catalog with a fixed `--epoch` produce byte-identical `.apkg`
files.
### What we deliberately do NOT do in Stage 2
- No editable cloze cards or "type the answer" templates — the
exam is multiple choice, and the deck mirrors that.
- No per-class packages. Class is a tag axis; package split is
by Prüfungsteil.
- No automatic edition diffs / "new questions since last build"
decks. Useful but out of scope.
- No upload to AnkiWeb. Decks are local artifacts.
+573 -259
View File
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -4,16 +4,14 @@ These tests inspect the generated `.apkg` package structure directly:
ZIP entries, media map, and the SQLite collection database. They do not ZIP entries, media map, and the SQLite collection database. They do not
require Anki itself to be installed. require Anki itself to be installed.
""" """
from __future__ import annotations
import json import json
import sqlite3 import sqlite3
import tempfile import tempfile
import unittest
import zipfile import zipfile
from pathlib import Path from pathlib import Path
import unittest
import amateurfunk_anki as aa import amateurfunk_anki as aa
-1
View File
@@ -4,7 +4,6 @@ Network-bound tests are not included; the real BNetzA fetch lives in
the manual smoke-test invocation. Everything here runs against fixture the manual smoke-test invocation. Everything here runs against fixture
ZIPs built in temp directories. ZIPs built in temp directories.
""" """
from __future__ import annotations
import hashlib import hashlib
import json import json