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
question catalog ("Fragenkatalog") published by the Bundesnetzagentur
(BNetzA) and extracts the structured JSON + SVG image assets for further
use (study apps, flashcards, training tools, etc.).
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, and design decisions live
in `DESIGN.md`. This file is a short orientation for anyone (human or
agent) opening the project.
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
@@ -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`
- 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`.
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
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.
2. Verify it is a valid ZIP and contains the expected JSON + SVG files.
3. Extract to a target directory (default: `./data/<edition>/`).
4. Emit a small `manifest.json` next to the data: source URL, fetched-at
timestamp, `Last-Modified` from the server, JSON edition metadata,
sha256 of the ZIP.
4. Emit a small `manifest.json` next to the data: source URL,
fetched-at timestamp, `Last-Modified` from the server, JSON edition
metadata, sha256 of the ZIP.
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
previous manifest; the ZIP is deleted by default after extraction,
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
study app, multi-edition diffing, mirroring the PDF.
### Stage 2 — `amateurfunk_anki.py`
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
- Python 3.11+, standard library only where reasonable (`urllib`,
`zipfile`, `hashlib`, `json`, `pathlib`). Add deps only if they pay
for themselves — none expected for v1.
- Single-purpose script: `amateurfunk_fetch.py` (or a tiny package if
it grows). No framework, no CLI library beyond `argparse`.
- Downloaded data is treated as a build artifact: kept under `data/`
and gitignored.
- 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 beyond `argparse`.
- 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/` and `anki/`, both
gitignored.
- License attribution string (required by DL-DE→BY-2.0) is preserved
verbatim from the upstream `README.txt` whenever we redistribute the
data.
@@ -67,10 +99,14 @@ study app, multi-edition diffing, mirroring the PDF.
## Working on this repo
- Start from `DESIGN.md` — it has the JSON schema, the question/answer
conventions (answer A is always correct, B/C/D are distractors), the
LaTeX-in-questions caveat, and the SVG naming convention.
conventions (answer A is always correct upstream → consumers shuffle
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
verified against the live BNetzA site.
- 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.
- 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.