` ihrer
Nummer; in § 12 Abs. 4 WaffG etwa stehen die Buchstaben zu Nr. 3 als
eigene Liste daneben. Ohne diese Korrektur entstünde daraus eine
„Nr. a", die es nicht gibt – und eine Fundstelle darauf käme durch die
Prüfung.
Steht dagegen gar keine Nummer voran, ist die Buchstabengliederung echt:
§ 3a Abs. 2 SprengG zählt unmittelbar mit „a)" und „b)" auf.
"""
ergebnis: dict[str, list[str]] = {}
letzte_nummer: str | None = None
for marke, unter in gliederung.items():
if NUMMER_MARKE.match(marke):
ergebnis[marke] = list(unter)
letzte_nummer = marke
elif letzte_nummer is not None:
ergebnis[letzte_nummer].append(marke)
ergebnis[letzte_nummer].extend(unter)
else:
ergebnis[marke] = list(unter)
return ergebnis
def gliederung_von(element: ET.Element, ort: str = '') -> dict[str, list[str]]:
"""Gliederung eines Absatz-Blocks als Marke -> Untermarken."""
gliederung: dict[str, list[str]] = {}
for dl in direkte_listen(element):
for marke, unter in liste_lesen(dl, ort):
# Bei mehreren Listen im selben Absatz gewinnt die erste Nennung;
# doppelte Marken kommen im Gesetzestext nicht vor.
gliederung.setdefault(marke, unter)
return falten(gliederung)
def absaetze_lesen(bloecke: list[tuple[str, dict[str, list[str]]]]) -> dict[str, Absatz]:
"""Ordnet die ``-Blöcke ihren Absatznummern zu.
Ein Absatz kann sich über mehrere `
` erstrecken. Ein Block ohne eigene
Nummer gehört deshalb zum zuletzt begonnenen Absatz.
"""
absaetze: dict[str, Absatz] = {}
laufend: str | None = None
for text, gliederung in bloecke:
treffer = ABSATZ_MUSTER.match(text)
if treffer:
laufend = treffer.group(1)
absaetze[laufend] = Absatz(
nummer=laufend,
text=text[treffer.end() :],
gliederung=dict(gliederung),
)
elif laufend is not None:
absaetze[laufend].text += '\n' + text
for marke, unter in gliederung.items():
absaetze[laufend].gliederung.setdefault(marke, unter)
return absaetze
def norm_lesen(norm: ET.Element) -> Norm | None:
md = norm.find('metadaten')
if md is None:
return None
bezeichnung = (md.findtext('enbez') or '').strip()
if not bezeichnung:
return None
inhalt = norm.find('textdaten/text/Content')
bloecke: list[tuple[str, dict[str, list[str]]]] = []
if inhalt is not None:
for block in inhalt:
text = text_von(block)
if text:
bloecke.append((text, gliederung_von(block, bezeichnung)))
ist_anlage = bezeichnung.lower().startswith('anlage')
return Norm(
bezeichnung=bezeichnung,
titel=(md.findtext('titel') or '').strip(),
text='\n'.join(t for t, _ in bloecke),
absaetze={} if ist_anlage else absaetze_lesen(bloecke),
ist_anlage=ist_anlage,
)
def standangabe_lesen(md: ET.Element) -> str:
"""Die Fassungsangabe des Gesetzes – der Änderungsstand, nicht die Neufassung.
Das amtliche XML führt ```` je nach Gesetz ein- oder zweimal.
Wer seit seiner Neufassung geändert wurde, trägt zuerst einen Eintrag vom
Typ ``Neuf`` („Neugefasst durch Bek. v. …“) und danach den vom Typ
``Stand`` („zuletzt geändert durch …“). Gebraucht wird der zweite: Er sagt,
welche Fassung hier vorliegt.
Bis Fassung 0.27.2 stand hier ``md.find('standangabe/standkommentar')`` –
der **erste** Eintrag, ohne Blick auf den Typ. Bei StGB, SprengG und
1. SprengV, zusammen 40 der 148 ausgelieferten Normen, erschien dadurch
unter dem Wortlaut die Bekanntmachung statt des Änderungsstands: beim StGB
die vom 13.11.1998 statt der Änderung vom 20.03.2026, 28 Jahre daneben.
Schwerer wog die zweite Wirkung: Der Freigabeschritt „Gesetzesstand
ansehen“ in ``docs/veroeffentlichen.md`` vergleicht diese Zeichenkette mit
gesetze-im-internet.de – und eine Neufassungs-Bekanntmachung ändert sich
durch keine Gesetzesänderung. Für diese drei Gesetze konnte der Schritt
nie anschlagen.
Fehlt ein ``Stand``-Eintrag, gilt der erste vorhandene: Ein Gesetz, das
seit seiner Neufassung unverändert ist, hat in ihr seine Fassungsangabe.
"""
erste = ''
for angabe in md.findall('standangabe'):
text = (angabe.findtext('standkommentar') or '').strip()
if not text:
continue
if not erste:
erste = text
if (angabe.findtext('standtyp') or '').strip() == 'Stand':
return text
return erste
def gesetz_lesen(pfad: Path) -> tuple[dict[str, str], dict[str, Norm]]:
wurzel = ET.parse(pfad).getroot()
kopf: dict[str, str] = {}
normen: dict[str, Norm] = {}
for norm_element in wurzel.findall('norm'):
md = norm_element.find('metadaten')
if md is None:
continue
# Der erste Datensatz trägt die Angaben zum Gesetz als Ganzes.
if not kopf:
kopf = {
'jurabk': (md.findtext('jurabk') or '').strip(),
'amtabk': (md.findtext('amtabk') or '').strip(),
'langtitel': (md.findtext('langue') or '').strip(),
'ausfertigung': (md.findtext('ausfertigung-datum') or '').strip(),
'stand': standangabe_lesen(md),
}
norm = norm_lesen(norm_element)
if norm is not None and (norm.bezeichnung.startswith('§') or norm.ist_anlage):
normen[norm.bezeichnung] = norm
return kopf, normen
def abzugsdatum(pfad: Path) -> str:
"""Wann dieser Abzug gezogen wurde – das Datum der XML-Datei.
**Warum die Dateizeit und nicht die Laufzeit.** Der Erzeuger liest die
XML-Dateien, er holt sie nicht. Ein Datum aus dem Lauf sagte also nur,
wann der Index neu gebaut wurde, und nicht, wie alt der Gesetzestext
darin ist. Genau das war die Lücke: `content/gesetze/index.json` führte
die Standangabe des Gesetzes („Zuletzt geändert durch …"), aber nirgends
stand, wann jemand zuletzt nachgesehen hat, ob es dabei geblieben ist.
Am 01.09.2026 lagen zwischen dem ältesten und dem jüngsten Abzug
**sechs Jahre**.
Bewusst kein Test, der mit dem Kalender rot wird: Eine Prüfung, die
allein durch Zeitablauf ausfällt, ist am Tag ihres Ausfalls kein Befund,
sondern eine Störung – dieselbe Bauart, die in `selbstsicherung.test.ts`
schon einmal zugeschlagen hat. Sichtbar gemacht wird das Alter hier und
in der Liste unter `docs/veroeffentlichen.md`; entscheiden muss es ein
Mensch, denn nur er kann nachsehen, ob sich die Vorschrift geändert hat.
"""
return datetime.fromtimestamp(pfad.stat().st_mtime).date().isoformat()
def main() -> int:
gesetze: dict[str, object] = {}
for kuerzel, (datei, bezeichnung, kennung) in QUELLEN.items():
pfad = GESETZE / datei
if not pfad.exists():
print(f'FEHLT: {kuerzel} ({datei})')
return 1
kopf, normen = gesetz_lesen(pfad)
paragrafen = {b: n for b, n in normen.items() if not n.ist_anlage}
anlagen = {b: n for b, n in normen.items() if n.ist_anlage}
gesetze[kuerzel] = {
'bezeichnung': bezeichnung,
'langtitel': kopf.get('langtitel', ''),
'stand': kopf.get('stand', ''),
'ausfertigung': kopf.get('ausfertigung', ''),
'quelle': f'https://www.gesetze-im-internet.de/{kennung}/',
'abzug': abzugsdatum(pfad),
'normen': {
b: {
'titel': n.titel,
'ist_anlage': n.ist_anlage,
'absaetze': {
a.nummer: {'gliederung': a.gliederung, 'text': a.text}
for a in n.absaetze.values()
},
'text': n.text,
}
for b, n in normen.items()
},
}
gegliedert = sum(
1 for n in paragrafen.values() for a in n.absaetze.values() if a.gliederung
)
print(
f'{kuerzel:10} {len(paragrafen):4} Paragrafen, {len(anlagen)} Anlagen, '
f'{gegliedert:4} gegliederte Absaetze'
)
ergebnis = {
'hinweis': (
'Maschinell erzeugt aus dem amtlichen XML von gesetze-im-internet.de '
'(Bundesministerium der Justiz / juris GmbH). Nicht von Hand aendern - '
'neu erzeugen mit: python data-pipeline/gesetze_index.py'
),
'gesetze': gesetze,
}
with io.open(ZIEL, 'w', encoding='utf-8', newline='\n') as datei_aus:
json.dump(ergebnis, datei_aus, ensure_ascii=False, indent=1)
print(f'\ngeschrieben: {ZIEL.name} ({ZIEL.stat().st_size / 1_048_576:.1f} MB)')
# Was beim Lesen nicht aufging, gehört ausgesprochen. Ein Absatz mit
# unlesbarer Gliederung fällt sonst niemandem auf – und an ihm scheitern
# anschliessend richtige Zitate.
if MARKENBEFUNDE:
print(f'\nUnsaubere Gliederungsmarken im amtlichen XML ({len(MARKENBEFUNDE)}):')
for befund in MARKENBEFUNDE:
print(f' {befund}')
return 0
if __name__ == '__main__':
raise SystemExit(main())