Initial design
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
# Amateurfunk — BNetzA Question Catalog Downloader
|
||||
|
||||
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.).
|
||||
|
||||
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.
|
||||
|
||||
## 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 tool): same path with
|
||||
`Pruefungsfragen.pdf` instead of `PruefungsfragenZIP.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.
|
||||
|
||||
## Scope of the tool (initial)
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Out of scope for v1 (kept for later): rendering LaTeX/SVG, building a
|
||||
study app, multi-edition diffing, mirroring the PDF.
|
||||
|
||||
## 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.
|
||||
- License attribution string (required by DL-DE→BY-2.0) is preserved
|
||||
verbatim from the upstream `README.txt` whenever we redistribute the
|
||||
data.
|
||||
|
||||
## 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.
|
||||
- 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
|
||||
must not hard-code the current name.
|
||||
@@ -0,0 +1,441 @@
|
||||
# Design Notes — BNetzA Amateurfunk Question Catalog Downloader
|
||||
|
||||
Status: **design only, no code yet.** This document captures the
|
||||
source-discovery work and the proposed shape of the tool. Everything
|
||||
below was verified against the live BNetzA site in May 2026.
|
||||
|
||||
---
|
||||
|
||||
## 1. Source of the latest questions
|
||||
|
||||
The Bundesnetzagentur (BNetzA) is the authoritative publisher. They
|
||||
provide the exam catalog in two parallel formats from the same landing
|
||||
page:
|
||||
|
||||
- **PDF** — human-readable, ~5 MB.
|
||||
- **ZIP** — machine-readable, ~3 MB, contains JSON + SVG figures. This
|
||||
is the format we use.
|
||||
|
||||
### Landing page
|
||||
|
||||
- Short URL: `https://www.bnetza.de/amateurfunk-fragenkatalog`
|
||||
- HTTP 301 → `SharedDocs/Downloads/.../Fragenkatalog/KurzURLFragenkatalog.html`
|
||||
- The short URL points at an HTML page, not at the ZIP itself. We do
|
||||
not use it for fetching; it is useful only as a citation/attribution
|
||||
target.
|
||||
|
||||
### Direct download URLs (verified 2026-05-20)
|
||||
|
||||
- **ZIP (what we fetch):**
|
||||
`https://www.bundesnetzagentur.de/SharedDocs/Downloads/DE/Sachgebiete/Telekommunikation/Unternehmen_Institutionen/Frequenzen/Amateurfunk/Fragenkatalog/PruefungsfragenZIP.zip?__blob=publicationFile`
|
||||
- HTTP 200, `Content-Type: application/zip`, `Accept-Ranges: bytes`,
|
||||
serves a fresh `Last-Modified` header. Suitable for conditional
|
||||
fetch and idempotency checks.
|
||||
- **PDF (not used by this tool):**
|
||||
`https://www.bundesnetzagentur.de/SharedDocs/Downloads/DE/Sachgebiete/Telekommunikation/Unternehmen_Institutionen/Frequenzen/Amateurfunk/Fragenkatalog/Pruefungsfragen.pdf?__blob=publicationFile`
|
||||
|
||||
The `?__blob=publicationFile` query string is required — without it the
|
||||
CMS serves an HTML wrapper, not the binary.
|
||||
|
||||
### License
|
||||
|
||||
`DL-DE→BY-2.0` (Datenlizenz Deutschland – Namensnennung – Version 2.0,
|
||||
see `www.govdata.de/dl-de/by-2-0`). Commercial and non-commercial reuse
|
||||
is permitted with attribution. The exact attribution string required is
|
||||
spelled out in the ZIP's `README.txt`; we copy it verbatim into any
|
||||
redistribution and into our `manifest.json`.
|
||||
|
||||
### Edition tracking
|
||||
|
||||
BNetzA does not version the URL. The same `PruefungsfragenZIP.zip`
|
||||
path is updated in place when a new edition is published. To detect a
|
||||
new edition we rely on:
|
||||
|
||||
1. The HTTP `Last-Modified` response header.
|
||||
2. The sha256 of the downloaded ZIP.
|
||||
3. The `metadata` block inside the JSON (`edition`, `issued_on`,
|
||||
`valid_from`).
|
||||
|
||||
Current edition observed: `3. Auflage, März 2024` (issued 2024-03-20,
|
||||
valid from 2024-06-24).
|
||||
|
||||
---
|
||||
|
||||
## 2. ZIP contents (verified by extracting the live file)
|
||||
|
||||
```
|
||||
fragenkatalog3b.json — single JSON file, full question tree (~1.3 MB)
|
||||
README.txt — license + schema documentation
|
||||
svgs/ — 700+ figures (mostly SVG, a few PNG fallbacks)
|
||||
AB108_q.svg
|
||||
AB404_a.svg
|
||||
AB404_b.svg
|
||||
...
|
||||
NG302_q.png — a couple of questions ship a PNG alongside
|
||||
NG302_q.svg the SVG for display-problem fallbacks
|
||||
```
|
||||
|
||||
Total: 706 entries, 701 of them files. ~1750 questions across classes
|
||||
N, E, A (counts observed: N=571, E=463, A=716).
|
||||
|
||||
The JSON filename encodes the edition: `fragenkatalog3b.json` =
|
||||
3rd edition, revision b. A future edition will rename this file
|
||||
(`fragenkatalog4a.json`, etc.), so the loader must discover it from
|
||||
the archive (glob `fragenkatalog*.json`), not hard-code the name.
|
||||
|
||||
### `README.txt` highlights
|
||||
|
||||
- Confirms the two-part structure: JSON for catalog/questions, SVG for
|
||||
images.
|
||||
- Confirms the JSON schema (see section 3).
|
||||
- Notes that question text may contain **LaTeX** for formulas, intended
|
||||
to be rendered by something like KaTeX.
|
||||
|
||||
---
|
||||
|
||||
## 3. JSON schema
|
||||
|
||||
Top-level object:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"metadata": {
|
||||
"edition": "3. Auflage, März 2024",
|
||||
"issued_on": "2024-03-20",
|
||||
"valid_from": "2024-06-24",
|
||||
"license": "DL-DE->BY-2.0"
|
||||
},
|
||||
"sections": [ /* recursive section nodes */ ]
|
||||
}
|
||||
```
|
||||
|
||||
Each `section` node is either an **inner node** (contains nested
|
||||
`sections`) or a **leaf** (contains a `questions` list):
|
||||
|
||||
```jsonc
|
||||
// inner
|
||||
{ "title": "Prüfungsfragen im Prüfungsteil: Technische Kenntnisse",
|
||||
"sections": [ ... ] }
|
||||
|
||||
// leaf
|
||||
{ "title": "Allgemeine mathematische Grundkenntnisse und Größen",
|
||||
"questions": [ /* question objects */ ] }
|
||||
```
|
||||
|
||||
Question object (fields per the upstream README):
|
||||
|
||||
| field | meaning |
|
||||
|--------------------|-----------------------------------------------------------|
|
||||
| `number` | Catalog id, e.g. `NA103`, `AB404`. Also the SVG basename. |
|
||||
| `class` | License class: `"1"` = N, `"2"` = E, `"3"` = A. |
|
||||
| `question` | Question text. May contain LaTeX. |
|
||||
| `answer_a` | **The correct answer.** Always A. |
|
||||
| `answer_b` | Distractor. |
|
||||
| `answer_c` | Distractor. |
|
||||
| `answer_d` | Distractor. |
|
||||
| `picture_question` | Optional. Figure shown with the question stem. |
|
||||
| `picture_a`..`_d` | Optional. Per-choice figures. |
|
||||
|
||||
The `picture_*` fields, when present, contain a filename (e.g.
|
||||
`AB404_q.svg`) that resolves into `svgs/`. Convention observed in the
|
||||
archive: `<number>_q.svg` for the question figure, `<number>_a.svg` ..
|
||||
`<number>_d.svg` for per-answer figures. A small number of entries also
|
||||
ship a `.png` next to the `.svg`.
|
||||
|
||||
### Important consumer-side conventions
|
||||
|
||||
- **Answer A is always correct.** Anything that presents the questions
|
||||
to a learner must shuffle A/B/C/D before display, otherwise the
|
||||
exercise is trivial.
|
||||
- LaTeX in the question text and answers is unescaped — consumers
|
||||
render it (e.g. KaTeX/MathJax). The downloader does not transform it.
|
||||
- Classes are stored as string digits, not letter codes — map `"1"→N`,
|
||||
`"2"→E`, `"3"→A` for display.
|
||||
|
||||
Sample question (from `fragenkatalog3b.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"number": "NA103",
|
||||
"class": "1",
|
||||
"question": "Laut Datenblatt wiegen 100 m eines bestimmten Drahtes 210 g. Ein vorliegendes Drahtstück desselben Materials wiegt 55 g. Wie lang ist das Drahtstück in etwa?",
|
||||
"answer_a": "26,2 m",
|
||||
"answer_b": "382 m",
|
||||
"answer_c": "115 m",
|
||||
"answer_d": "38,2 m"
|
||||
}
|
||||
```
|
||||
|
||||
### Exam structure — how the catalog splits into exam parts and classes
|
||||
|
||||
The catalog already encodes two orthogonal axes. The downloader does
|
||||
not slice the data on disk, but any consumer needs to understand both
|
||||
or they will compute the wrong candidate study pool.
|
||||
|
||||
**Axis 1 — Exam part (Prüfungsteil).** The top-level `sections[]`
|
||||
array has exactly three entries, one per exam part:
|
||||
|
||||
| Top-level `title` | Question count | ID prefix |
|
||||
|----------------------------------------------------------------|---------------:|----------------|
|
||||
| `Prüfungsfragen im Prüfungsteil: Technische Kenntnisse` | 1374 | `N*`/`E*`/`A*` |
|
||||
| `Prüfungsfragen im Prüfungsteil: Betriebliche Kenntnisse` | 172 | `B*` |
|
||||
| `Prüfungsfragen im Prüfungsteil: Kenntnisse von Vorschriften` | 204 | `V*` |
|
||||
|
||||
Consumers can split on the section title (canonical) or on the
|
||||
question `number` first letter (shorthand). The first-letter mapping
|
||||
is: `A`/`E`/`N` → Technische; `B` → Betriebliche; `V` → Vorschriften.
|
||||
Inside Technische, the first letter additionally mirrors the license
|
||||
class (see Axis 2).
|
||||
|
||||
**Axis 2 — License class (`class` field on each question).**
|
||||
Values are `"1"`=N, `"2"`=E, `"3"`=A. Class distribution per exam
|
||||
part (counts verified against the live catalog, 3rd edition):
|
||||
|
||||
| Exam part | class 1 (N) | class 2 (E) | class 3 (A) |
|
||||
|-------------|------------:|------------:|------------:|
|
||||
| Technische | 195 | 463 | 716 |
|
||||
| Betriebliche| 172 | 0 | 0 |
|
||||
| Vorschriften| 204 | 0 | 0 |
|
||||
|
||||
Two things a consumer must know:
|
||||
|
||||
1. **Operational + Regulations are class-1-only in the data, but
|
||||
apply to every candidate.** BNetzA treats these as a shared
|
||||
foundation. Do not filter them by `class`.
|
||||
2. **In Technische, the `class` field is a floor, not an equality
|
||||
marker.** German amateur-radio exam knowledge is cumulative: a
|
||||
class-E candidate is expected to know everything at class N and
|
||||
E; a class-A candidate knows class N + E + A. Treating `class`
|
||||
as equality underreports the E and A study pools.
|
||||
|
||||
The candidate study pools work out as:
|
||||
|
||||
- **N**: 195 (Tech class 1) + 172 (Betr) + 204 (Vor) = **571**
|
||||
- **E**: (195 + 463) Tech + 172 + 204 = **1034**
|
||||
- **A**: (195 + 463 + 716) Tech + 172 + 204 = **1750**
|
||||
|
||||
The 1750 total exactly matches the full catalog, which confirms the
|
||||
floor interpretation: an A candidate's pool is the entire catalog.
|
||||
|
||||
**Downloader scope.** v1 does not split data on disk — the JSON tree
|
||||
carries both axes already and consumers slice it themselves. This
|
||||
subsection exists so that future consumers (study app, flashcards,
|
||||
diff tool) implement the slicing correctly without re-deriving it
|
||||
from the data.
|
||||
|
||||
---
|
||||
|
||||
## 4. Tool design (v1)
|
||||
|
||||
### Goal
|
||||
|
||||
Given no arguments, fetch the current BNetzA ZIP, extract it into a
|
||||
clean per-edition directory, and write a manifest. Re-running is a
|
||||
no-op when the upstream file has not changed.
|
||||
|
||||
### CLI shape
|
||||
|
||||
```
|
||||
amateurfunk-fetch [--out DIR] [--force] [--keep-zip]
|
||||
```
|
||||
|
||||
- `--out DIR` — output root (default `./data`). Each edition lands in
|
||||
`DIR/<edition-slug>/`, e.g. `data/2024-03-20-3-auflage/`.
|
||||
- `--force` — re-download and re-extract even if the existing manifest
|
||||
matches.
|
||||
- `--keep-zip` — keep the raw ZIP alongside the extracted tree (for
|
||||
archival). Default deletes it after successful extraction.
|
||||
|
||||
Exit codes: `0` success (extracted or up-to-date), `1` network /
|
||||
validation error, `2` invalid local state (e.g. a partial previous run
|
||||
the tool can't reconcile without `--force`).
|
||||
|
||||
### Steps
|
||||
|
||||
1. **HEAD** the ZIP URL to read `Last-Modified` (and `Content-Length`
|
||||
for a basic sanity range). If `DIR/manifest-latest.json` exists
|
||||
and its `http_last_modified` equals the current server value AND
|
||||
the target `DIR/<slug>/manifest.json` it points at is present and
|
||||
parseable, exit 0 unchanged. The manifest — not the raw ZIP — is
|
||||
the trusted record after a successful validated extraction; the
|
||||
ZIP sha256 stays in the manifest as provenance, not as something
|
||||
we re-verify on every run. (We delete the ZIP by default; there
|
||||
would be nothing to re-verify against.)
|
||||
2. **GET** the ZIP to a temp file in `DIR/.tmp/`. Stream to disk,
|
||||
compute sha256 on the fly. Enforce a compressed max size (e.g.
|
||||
50 MB) and, after open, a total uncompressed max size (e.g.
|
||||
200 MB) as a guardrail against zip-bomb-style upstream regressions.
|
||||
3. **Validate**, failing closed on structural problems:
|
||||
- `zipfile.is_zipfile()` is true.
|
||||
- All ZIP paths are relative and normalized: reject any entry
|
||||
whose name is absolute, contains a `..` segment, or whose
|
||||
`os.path.normpath`-result differs in a way that escapes the
|
||||
extraction root — defends against zip-slip.
|
||||
- **No symlink entries.** ZIPs created on Unix encode symlinks in
|
||||
the upper 16 bits of `ZipInfo.external_attr` (the POSIX mode
|
||||
field). Reject any entry where
|
||||
`(zi.external_attr >> 16) & 0o170000 == 0o120000` (S_IFLNK),
|
||||
in addition to the path checks above.
|
||||
- Sum of uncompressed sizes is below the configured cap.
|
||||
- Archive contains **exactly one** root-level `fragenkatalog*.json`.
|
||||
- Archive contains **more than 100 file entries** whose normalized
|
||||
path starts with `svgs/`. We do not require a standalone `svgs/`
|
||||
directory record — many ZIP producers omit directory entries
|
||||
and only emit file entries like `svgs/AB108_q.svg`.
|
||||
- The JSON parses and has top-level keys `metadata` and `sections`.
|
||||
- `metadata` carries the required keys `edition`, `issued_on`,
|
||||
`valid_from`, `license`. Extra keys are tolerated.
|
||||
- Every section node has either nested `sections` or a `questions`
|
||||
list (not both, not neither).
|
||||
- Every question has `number`, `class`, `question`, `answer_a`,
|
||||
`answer_b`, `answer_c`, `answer_d`.
|
||||
- For every `picture_*` reference in a question, the named file
|
||||
exists under `svgs/` in the archive. **Soft check:** missing
|
||||
references do not fail the extraction. They are collected into
|
||||
the manifest as `missing_pictures` so consumers can decide
|
||||
locally. Rationale: a single upstream typo should not brick the
|
||||
downloader for every consumer until BNetzA ships a fix.
|
||||
4. **Derive edition slug** from `metadata.issued_on` plus an edition
|
||||
ordinal parsed from `metadata.edition`, e.g.
|
||||
`"3. Auflage, März 2024"` + `2024-03-20` → `2024-03-20-3-auflage`.
|
||||
The ordinal is taken from the leading `\d+` in the edition string;
|
||||
if absent, fall back to `unknown-auflage`. Sortable, filesystem-
|
||||
safe, and independent of German month-name parsing. The full
|
||||
upstream `metadata.edition` is preserved verbatim in `manifest.json`.
|
||||
5. **Extract** into `DIR/<slug>.tmp/`. Replacement semantics:
|
||||
- If `DIR/<slug>/manifest.json` already exists and its recorded
|
||||
`zip_sha256` matches the freshly downloaded ZIP, skip extraction,
|
||||
update `manifest-latest.json` if needed, and exit 0.
|
||||
- If `DIR/<slug>/` exists but does not match and `--force` is not
|
||||
set, exit 2 with a clear message naming the offending directory.
|
||||
- With `--force`, after extraction completes into `DIR/<slug>.tmp/`,
|
||||
rename the existing `DIR/<slug>/` to `DIR/<slug>.bak/`, rename
|
||||
`DIR/<slug>.tmp/` into place, then remove `DIR/<slug>.bak/`. A
|
||||
crash between the two renames leaves a recoverable `.bak`
|
||||
directory.
|
||||
- **`.bak` collision policy:** if `DIR/<slug>.bak/` already exists
|
||||
before the forced replacement starts, exit 2 with a clear error
|
||||
naming the stale `.bak/` path. A leftover `.bak/` is evidence
|
||||
that a previous run crashed mid-rename; deciding whether to keep
|
||||
or delete it is the operator's call, not ours. The same applies
|
||||
to a stale `DIR/<slug>.tmp/`: refuse to overwrite, surface the
|
||||
path. Both checks happen *before* any rename — no destructive
|
||||
action without a clean predecessor state.
|
||||
Always copy the upstream `README.txt` verbatim into the extracted
|
||||
tree. The manifest also records the attribution string for
|
||||
convenience, but the file is the source of truth — lossless
|
||||
preservation beats parser-derived fields.
|
||||
6. **Write `DIR/<slug>/manifest.json`** with:
|
||||
- `source_url`
|
||||
- `fetched_at` (ISO 8601 UTC)
|
||||
- `http_last_modified` (verbatim from the server)
|
||||
- `zip_sha256`
|
||||
- `zip_size`
|
||||
- `json_filename` (the actual `fragenkatalog*.json` we found)
|
||||
- the full upstream `metadata` block
|
||||
- the verbatim attribution string from `README.txt`
|
||||
- `missing_pictures` (array of `{question_number, field, file}`,
|
||||
empty when the archive is clean)
|
||||
7. **Atomically update `DIR/manifest-latest.json`** to point at the
|
||||
current slug. Precise sequence:
|
||||
1. Write the new content to a sibling `manifest-latest.json.tmp`
|
||||
in the same directory as the target.
|
||||
2. `os.fsync(tmp_fd)` to flush the tmp file's contents to disk.
|
||||
3. `os.replace(tmp_path, final_path)` to atomically swap.
|
||||
4. On POSIX, open the containing directory and `os.fsync` its
|
||||
file descriptor so the rename itself is durable. Wrap in a
|
||||
`try/except OSError` and ignore on platforms where directory
|
||||
fsync is not supported (e.g. Windows) — the swap is still
|
||||
atomic, only its on-disk persistence guarantee differs.
|
||||
A symlink at `manifest-latest` would be nicer on POSIX but a
|
||||
small pointer file is portable.
|
||||
8. **Clean up** the temp ZIP (unless `--keep-zip`).
|
||||
|
||||
### Idempotency and safety
|
||||
|
||||
- Never extract directly into the final directory — always into a
|
||||
sibling `*.tmp` that is renamed on success. A crash mid-extract leaves
|
||||
the previous good edition untouched.
|
||||
- Network calls go through `urllib.request` with a sane User-Agent
|
||||
(`amateurfunk-fetch/<version> (+contact)`) and a timeout. Single
|
||||
retry on transient errors (no exponential-backoff library needed for
|
||||
a once-a-quarter download).
|
||||
- No telemetry. No mutation outside `--out`.
|
||||
|
||||
### Testing focus
|
||||
|
||||
The most valuable tests are behavioral, not network-bound. The real
|
||||
BNetzA fetch stays as an opt-in integration test (skipped by default);
|
||||
everything else runs against fixture ZIPs.
|
||||
|
||||
- Slug generation from sample `metadata` (covers normal, missing
|
||||
ordinal, unusual edition strings).
|
||||
- ZIP path rejection: absolute paths, `..` segments, symlinks.
|
||||
- Uncompressed-size cap triggers cleanly.
|
||||
- Validation failures: missing JSON, multiple `fragenkatalog*.json`,
|
||||
missing `svgs/`, malformed JSON, missing required top-level keys,
|
||||
missing required metadata/question keys, malformed section nodes.
|
||||
- `missing_pictures` is populated but extraction still succeeds when
|
||||
a `picture_*` reference doesn't resolve (soft check).
|
||||
- Manifest is well-formed and contains the upstream attribution and
|
||||
`metadata` block verbatim.
|
||||
- **`Last-Modified` round-trip**: after a successful extraction, a
|
||||
rerun with the same `Last-Modified` header on the HEAD response
|
||||
skips the GET entirely. This is the actual contract idempotency
|
||||
hangs on now that the ZIP is deleted by default.
|
||||
- `--force` path: existing non-matching `DIR/<slug>/` is replaced via
|
||||
the temp/bak rename dance, and a simulated crash between the two
|
||||
renames leaves a recoverable `.bak/`.
|
||||
- Atomic write of `manifest-latest.json`: a write that fails partway
|
||||
does not corrupt the existing pointer file.
|
||||
|
||||
### What we deliberately do NOT do in v1
|
||||
|
||||
- No PDF mirroring.
|
||||
- No question rendering (LaTeX, SVG).
|
||||
- No splitting by class / chapter on disk — the JSON tree already
|
||||
carries that structure and consumers can slice it themselves.
|
||||
- No diffing between editions. Useful, but a separate tool that reads
|
||||
two manifest dirs.
|
||||
- No mirror to S3 / a release artifact. Out of scope unless asked.
|
||||
|
||||
---
|
||||
|
||||
## 5. Resolved during review
|
||||
|
||||
These were open in the first draft and have been settled:
|
||||
|
||||
1. **Edition slug format** — resolved: derive from `issued_on` +
|
||||
numeric edition ordinal, e.g. `2024-03-20-3-auflage`. Avoids
|
||||
parsing German month names; preserves day-precision; sortable.
|
||||
2. **`manifest-latest.json` location** — at `DIR/` root (one stable
|
||||
pointer for consumers); per-edition `manifest.json` is the
|
||||
immutable record.
|
||||
3. **Idempotency without a retained ZIP** — resolved: the manifest is
|
||||
the trusted record after extraction. `Last-Modified` match is
|
||||
sufficient to skip the download; `zip_sha256` is recorded for
|
||||
provenance only.
|
||||
4. **README.txt preservation** — copy verbatim into the extracted
|
||||
tree alongside any derived attribution field in the manifest.
|
||||
5. **Missing picture references** — soft-validate (record in
|
||||
`missing_pictures`, do not fail). Rationale: avoid bricking the
|
||||
tool on an upstream typo.
|
||||
|
||||
## 6. Open questions
|
||||
|
||||
These do not block writing v1:
|
||||
|
||||
1. **PNG fallbacks** — the archive ships PNGs for ~a handful of
|
||||
figures alongside the SVGs. v1 just extracts everything as-is. A
|
||||
future renderer can prefer SVG and fall back to PNG transparently.
|
||||
2. **Schema drift safety** — if a future edition adds or renames
|
||||
fields, the validator should warn but not fail. v1 fails closed
|
||||
on missing required top-level/metadata/question keys but tolerates
|
||||
extra keys. We can soften this further if BNetzA evolves the
|
||||
format.
|
||||
3. **Packaging** — v1 stays a single-file script with `argparse`
|
||||
(`amateurfunk_fetch.py`). Considered and declined: a
|
||||
`pyproject.toml` package with a console-script entry point. For a
|
||||
tool that runs at most quarterly, the packaging ceremony does not
|
||||
pay for itself. Revisit if the scope grows (e.g. importable from
|
||||
another project, distributed on PyPI).
|
||||
Reference in New Issue
Block a user