Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f3eb23547 | |||
| d66f185fdd | |||
| 775560a64c | |||
| ebaec63963 | |||
| 3aa04fff4b |
16 changed files with 984 additions and 173 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -13,6 +13,11 @@ __pycache__/
|
||||||
# Eigene Ergebnisse und Testläufe
|
# Eigene Ergebnisse und Testläufe
|
||||||
output/
|
output/
|
||||||
|
|
||||||
|
# Arbeitsordner je Video, fertige Abschriften und Quellenangaben: nur lokal
|
||||||
|
abschriften/
|
||||||
|
fertig/
|
||||||
|
*.quelle.json
|
||||||
|
|
||||||
# Echte Reden (Urheberrecht): nur lokal; die Tests nutzen tests/daten/
|
# Echte Reden (Urheberrecht): nur lokal; die Tests nutzen tests/daten/
|
||||||
testdaten/
|
testdaten/
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,14 +16,15 @@ ist. Eine knappe technische Übersicht steht in der [README](README.md).
|
||||||
7. [Schritt 3: Korrigieren](#7-schritt-3-korrigieren)
|
7. [Schritt 3: Korrigieren](#7-schritt-3-korrigieren)
|
||||||
8. [Schritt 4: Ergebnis prüfen](#8-schritt-4-ergebnis-prüfen)
|
8. [Schritt 4: Ergebnis prüfen](#8-schritt-4-ergebnis-prüfen)
|
||||||
9. [Schritt 5: Nachbessern und erneut laufen lassen](#9-schritt-5-nachbessern-und-erneut-laufen-lassen)
|
9. [Schritt 5: Nachbessern und erneut laufen lassen](#9-schritt-5-nachbessern-und-erneut-laufen-lassen)
|
||||||
10. [Nur Ausschnitte bearbeiten (Zeitauswahl)](#10-nur-ausschnitte-bearbeiten-zeitauswahl)
|
10. [Schritt 6: Abschließen und aufräumen](#10-schritt-6-abschließen-und-aufräumen)
|
||||||
11. [Englische Beiträge](#11-englische-beiträge)
|
11. [Nur Ausschnitte bearbeiten (Zeitauswahl)](#11-nur-ausschnitte-bearbeiten-zeitauswahl)
|
||||||
12. [Dateien, die entstehen](#12-dateien-die-entstehen)
|
12. [Englische Beiträge](#12-englische-beiträge)
|
||||||
13. [Zwischenstände, Abbruch und Fortsetzen](#13-zwischenstände-abbruch-und-fortsetzen)
|
13. [Dateien, die entstehen](#13-dateien-die-entstehen)
|
||||||
14. [Ein anderes Sprachmodell verwenden](#14-ein-anderes-sprachmodell-verwenden)
|
14. [Zwischenstände, Abbruch und Fortsetzen](#14-zwischenstände-abbruch-und-fortsetzen)
|
||||||
15. [Fehlermeldungen und Lösungen](#15-fehlermeldungen-und-lösungen)
|
15. [Ein anderes Sprachmodell verwenden](#15-ein-anderes-sprachmodell-verwenden)
|
||||||
16. [Tipps und häufige Fragen](#16-tipps-und-häufige-fragen)
|
16. [Fehlermeldungen und Lösungen](#16-fehlermeldungen-und-lösungen)
|
||||||
17. [Kurzreferenz aller Befehle](#17-kurzreferenz-aller-befehle)
|
17. [Tipps und häufige Fragen](#17-tipps-und-häufige-fragen)
|
||||||
|
18. [Kurzreferenz aller Befehle](#18-kurzreferenz-aller-befehle)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -57,9 +58,15 @@ Die Werkzeuge im Überblick:
|
||||||
| `vtt2txt.py` | Untertiteldatei (`.vtt`) → Rohtext |
|
| `vtt2txt.py` | Untertiteldatei (`.vtt`) → Rohtext |
|
||||||
| `glossar_vorschlag.py` | Entwurf für ein Glossar (vermutete Hörfehler bei Namen und Begriffen) |
|
| `glossar_vorschlag.py` | Entwurf für ein Glossar (vermutete Hörfehler bei Namen und Begriffen) |
|
||||||
| `aenderungen.py` | Änderungsbericht für vorhandene Dateien erstellen |
|
| `aenderungen.py` | Änderungsbericht für vorhandene Dateien erstellen |
|
||||||
|
| `abschliessen.py` | fertige Abschrift mit Quellenangabe nach `fertig/`, Arbeitsordner aufräumen |
|
||||||
|
|
||||||
Für den Normalfall braucht man nur `abschrift_korrigieren.py`, für bessere
|
Für den Normalfall braucht man nur `abschrift_korrigieren.py` und am Ende
|
||||||
Ergebnisse zusätzlich ein Glossar.
|
`abschliessen.py`, für bessere Ergebnisse zusätzlich ein Glossar.
|
||||||
|
|
||||||
|
**Wo die Dateien liegen:** Jedes Video bekommt einen eigenen Arbeitsordner
|
||||||
|
`abschriften/<Name>/`, in dem alles zu diesem Video entsteht. Ist die Abschrift
|
||||||
|
fertig, bleibt nur die Ergebnisdatei in `fertig/`. Gemeinsame Glossare liegen in
|
||||||
|
`glossare/`. Details in [Abschnitt 13](#13-dateien-die-entstehen).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -95,7 +102,7 @@ Environment="OLLAMA_NUM_PARALLEL=1"
|
||||||
|
|
||||||
Mit `OLLAMA_NUM_PARALLEL=4` landet ein Drittel des Modells auf der CPU, und ein
|
Mit `OLLAMA_NUM_PARALLEL=4` landet ein Drittel des Modells auf der CPU, und ein
|
||||||
Lauf dauert etwa zwölfmal so lange. Mit weniger Grafikspeicher hilft ein kleineres
|
Lauf dauert etwa zwölfmal so lange. Mit weniger Grafikspeicher hilft ein kleineres
|
||||||
Modell (siehe [Abschnitt 14](#14-ein-anderes-sprachmodell-verwenden)), auf Kosten
|
Modell (siehe [Abschnitt 15](#15-ein-anderes-sprachmodell-verwenden)), auf Kosten
|
||||||
der Qualität.
|
der Qualität.
|
||||||
|
|
||||||
### 2.3 Prüfen, ob alles bereit ist
|
### 2.3 Prüfen, ob alles bereit ist
|
||||||
|
|
@ -117,6 +124,10 @@ Lassen sich die Programme nicht mit `./…` starten („Keine Berechtigung“),
|
||||||
ausführbar machen: `chmod +x *.py`. Alternativ immer `python3 programm.py …`
|
ausführbar machen: `chmod +x *.py`. Alternativ immer `python3 programm.py …`
|
||||||
aufrufen.
|
aufrufen.
|
||||||
|
|
||||||
|
**Immer im Programmordner arbeiten.** Die Ordner `abschriften/`, `fertig/` und
|
||||||
|
`glossare/` werden relativ zum aktuellen Verzeichnis angelegt bzw. gesucht. Wer die
|
||||||
|
Programme aus einem anderen Verzeichnis startet, bekommt dort eigene, leere Ordner.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Schnellstart
|
## 3. Schnellstart
|
||||||
|
|
@ -133,16 +144,25 @@ URLs die Shell.
|
||||||
Das Programm
|
Das Programm
|
||||||
|
|
||||||
1. lädt die Untertitel in der Originalsprache des Videos,
|
1. lädt die Untertitel in der Originalsprache des Videos,
|
||||||
2. speichert den Rohtext, z. B. `GGBLahnstein_Prof_Gabriele_Krone-Schmalz_Respekt_geht_anders_2026-07-30.txt`,
|
2. legt den Arbeitsordner an, z. B.
|
||||||
3. korrigiert ihn abschnittsweise und gliedert ihn in Absätze,
|
`abschriften/GGBLahnstein_Prof_Gabriele_Krone-Schmalz_Respekt_geht_anders_2026-07-30/`
|
||||||
4. schreibt das Ergebnis nach `…_2026-07-30.korrigiert.txt`
|
(kurz `abschriften/<Name>/`),
|
||||||
5. und den Änderungsbericht nach `…_2026-07-30.aenderungen.html`.
|
3. speichert dort den Rohtext `<Name>.txt` und die Quellenangabe `<Name>.quelle.json`
|
||||||
|
(Titel, Kanal, Datum, URL),
|
||||||
|
4. korrigiert den Text abschnittsweise und gliedert ihn in Absätze,
|
||||||
|
5. schreibt das Ergebnis nach `<Name>.korrigiert.txt`
|
||||||
|
6. und den Änderungsbericht nach `<Name>.aenderungen.html`.
|
||||||
|
|
||||||
Eine einstündige Rede braucht mit `qwen3:32b` auf einer RTX 3090 etwa 6–8
|
Eine einstündige Rede braucht mit `qwen3:32b` auf einer RTX 3090 etwa 6–8
|
||||||
Minuten. Danach den Bericht im Browser öffnen und die Liste **„Zu prüfen“**
|
Minuten. Danach den Bericht im Browser öffnen und die Liste **„Zu prüfen“**
|
||||||
durchgehen (siehe [Abschnitt 8](#8-schritt-4-ergebnis-prüfen)).
|
durchgehen (siehe [Abschnitt 8](#8-schritt-4-ergebnis-prüfen)). Ist alles erledigt:
|
||||||
|
|
||||||
Mit eigenem Dateinamen:
|
```bash
|
||||||
|
./abschliessen.py abschriften/<Name>
|
||||||
|
# → fertig/<Name>.txt mit Quellenkopf; der Arbeitsordner wird nach Rückfrage gelöscht
|
||||||
|
```
|
||||||
|
|
||||||
|
Mit eigenem Dateinamen (ohne Arbeitsordner):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./abschrift_korrigieren.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -o lahnstein.korrigiert.txt
|
./abschrift_korrigieren.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -o lahnstein.korrigiert.txt
|
||||||
|
|
@ -155,8 +175,10 @@ und einem passenden Glossar. Ein Lauf dauert etwa zwei bis drei Minuten und zeig
|
||||||
Ergebnisse, auch den Änderungsbericht:
|
Ergebnisse, auch den Änderungsbericht:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./abschrift_korrigieren.py -i tests/daten/beispiel.de.txt -g tests/daten/beispiel.glossar.txt -o beispiel.korrigiert.txt
|
./abschrift_korrigieren.py -i tests/daten/beispiel.de.txt -g tests/daten/beispiel.glossar.txt \
|
||||||
# → beispiel.korrigiert.txt, beispiel.aenderungen.html
|
-o abschriften/beispiel/beispiel.korrigiert.txt
|
||||||
|
# → abschriften/beispiel/: beispiel.korrigiert.txt, beispiel.aenderungen.html
|
||||||
|
./abschliessen.py abschriften/beispiel # Abschluss ausprobieren
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -168,22 +190,28 @@ besonders bei vielen Namen, lohnt dieser Ablauf:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
YouTube-URL
|
YouTube-URL
|
||||||
│ Schritt 1: extract_text.py → rede.txt (Rohtext)
|
│ Schritt 1: extract_text.py → abschriften/<Name>/<Name>.txt (Rohtext)
|
||||||
▼
|
▼
|
||||||
Rohtext ansehen
|
Rohtext ansehen
|
||||||
│ Schritt 2: glossar_vorschlag.py → rede.glossar.entwurf.txt
|
│ Schritt 2: glossar_vorschlag.py → <Name>.glossar.entwurf.txt
|
||||||
│ prüfen, übernehmen → glossare/rede.glossar.txt
|
│ prüfen, übernehmen → <Name>.glossar.txt (Video-Glossar)
|
||||||
|
│ bzw. glossare/<sprecher>.glossar.txt
|
||||||
▼
|
▼
|
||||||
│ Schritt 3: abschrift_korrigieren.py → rede.korrigiert.txt
|
│ Schritt 3: abschrift_korrigieren.py → <Name>.korrigiert.txt
|
||||||
│ rede.aenderungen.html
|
│ <Name>.aenderungen.html
|
||||||
▼
|
▼
|
||||||
│ Schritt 4: Bericht prüfen („Zu prüfen“)
|
│ Schritt 4: Bericht prüfen („Zu prüfen“), Text von Hand nacharbeiten
|
||||||
▼
|
▼
|
||||||
│ Schritt 5: Glossar ergänzen, erneut laufen lassen
|
│ Schritt 5: Glossar ergänzen, erneut laufen lassen
|
||||||
▼ (nur Geändertes wird neu berechnet)
|
│ (nur Geändertes wird neu berechnet)
|
||||||
|
▼
|
||||||
|
│ Schritt 6: abschliessen.py → fertig/<Name>.txt, Arbeitsordner gelöscht
|
||||||
|
▼
|
||||||
fertige Abschrift
|
fertige Abschrift
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Alle Dateien eines Videos liegen bis zum Abschluss in `abschriften/<Name>/`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Schritt 1: Rohtext holen
|
## 5. Schritt 1: Rohtext holen
|
||||||
|
|
@ -191,7 +219,8 @@ fertige Abschrift
|
||||||
### 5.1 Von YouTube: `extract_text.py`
|
### 5.1 Von YouTube: `extract_text.py`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./extract_text.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -o rede.txt
|
./extract_text.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ'
|
||||||
|
# → abschriften/<Name>/<Name>.txt und <Name>.quelle.json
|
||||||
```
|
```
|
||||||
|
|
||||||
Das Programm zeigt Titel, gewählte Untertitelspur und Ziel an:
|
Das Programm zeigt Titel, gewählte Untertitelspur und Ziel an:
|
||||||
|
|
@ -201,8 +230,10 @@ Video: Prof. Gabriele Krone-Schmalz – Respekt geht anders – …
|
||||||
Lade automatische Untertitel „de-orig“ …
|
Lade automatische Untertitel „de-orig“ …
|
||||||
```
|
```
|
||||||
|
|
||||||
Ohne `-o` entsteht ein Name aus Kanal, Titel und Datum (siehe
|
Ohne `-o` entsteht ein Name aus Kanal, Titel und Datum, und die Datei kommt in den
|
||||||
[Abschnitt 12.2](#122-standard-dateinamen)).
|
Arbeitsordner `abschriften/<Name>/` (siehe [Abschnitt 13.2](#132-standard-dateinamen)).
|
||||||
|
Mit `-o rede.txt` landet sie dort, wo man will; die Quellenangabe `rede.quelle.json`
|
||||||
|
entsteht daneben.
|
||||||
|
|
||||||
**Welche Untertitel werden genommen?**
|
**Welche Untertitel werden genommen?**
|
||||||
|
|
||||||
|
|
@ -223,16 +254,19 @@ versteht `abschrift_korrigieren.py`.
|
||||||
Soll die Original-Untertiteldatei (`.vtt`, mit Zeitstempeln) erhalten bleiben:
|
Soll die Original-Untertiteldatei (`.vtt`, mit Zeitstempeln) erhalten bleiben:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./yt_untertitel.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -n Lahnstein -d untertitel
|
./yt_untertitel.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -n Lahnstein
|
||||||
# → untertitel/Lahnstein.de-orig.vtt
|
# → abschriften/Lahnstein/Lahnstein.de-orig.vtt (+ Lahnstein.quelle.json)
|
||||||
./vtt2txt.py -i untertitel/Lahnstein.de-orig.vtt
|
./vtt2txt.py -i abschriften/Lahnstein/Lahnstein.de-orig.vtt
|
||||||
# → untertitel/Lahnstein.de-orig.txt
|
# → abschriften/Lahnstein/Lahnstein.de-orig.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`-n` bestimmt den Namen von Datei und Arbeitsordner; mit `-d` wählt man ein anderes
|
||||||
|
Zielverzeichnis.
|
||||||
|
|
||||||
Achtung: `yt_untertitel.py` nimmt ohne `-l` Deutsch (`de`), nicht die
|
Achtung: `yt_untertitel.py` nimmt ohne `-l` Deutsch (`de`), nicht die
|
||||||
Originalsprache. Für englische Videos `-l en` angeben.
|
Originalsprache. Für englische Videos `-l en` angeben.
|
||||||
|
|
||||||
Die `.vtt`-Datei braucht man für die [Zeitauswahl](#10-nur-ausschnitte-bearbeiten-zeitauswahl)
|
Die `.vtt`-Datei braucht man für die [Zeitauswahl](#11-nur-ausschnitte-bearbeiten-zeitauswahl)
|
||||||
mit vorhandenen Dateien. `abschrift_korrigieren.py` nimmt sie auch direkt, der
|
mit vorhandenen Dateien. `abschrift_korrigieren.py` nimmt sie auch direkt, der
|
||||||
Zwischenschritt über `vtt2txt.py` ist dann nur nötig, um den Rohtext anzusehen
|
Zwischenschritt über `vtt2txt.py` ist dann nur nötig, um den Rohtext anzusehen
|
||||||
oder ein Glossar daraus zu erstellen.
|
oder ein Glossar daraus zu erstellen.
|
||||||
|
|
@ -293,33 +327,43 @@ Regeln für feste Ersetzungen:
|
||||||
die Ersetzungen ohne Treffer. Bei einem Glossar für genau diesen Beitrag deutet
|
die Ersetzungen ohne Treffer. Bei einem Glossar für genau diesen Beitrag deutet
|
||||||
„ohne Treffer“ auf einen Tippfehler.
|
„ohne Treffer“ auf einen Tippfehler.
|
||||||
|
|
||||||
### 6.2 Mehrere Glossare kombinieren
|
### 6.2 Gemeinsame Glossare und Video-Glossare
|
||||||
|
|
||||||
Bewährt hat sich ein **gemeinsames Glossar** pro Redner oder Kanal (wiederkehrende
|
Bewährt hat sich ein **gemeinsames Glossar** pro Redner oder Kanal (wiederkehrende
|
||||||
Namen und Hörfehler) plus eines **je Beitrag**:
|
Namen und Hörfehler) plus ein **Video-Glossar** je Beitrag:
|
||||||
|
|
||||||
|
| Art | Ablage | Verwendung |
|
||||||
|
|---|---|---|
|
||||||
|
| gemeinsames Glossar | `glossare/krone-schmalz.glossar.txt` | mit `-g` angeben |
|
||||||
|
| Video-Glossar | `abschriften/<Name>/<Name>.glossar.txt`, heißt wie sein Ordner | **automatisch** |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./abschrift_korrigieren.py -i rede.txt -g glossare/krone-schmalz.glossar.txt -g glossare/rede.glossar.txt
|
./abschrift_korrigieren.py -i abschriften/<Name>/<Name>.txt -g glossare/krone-schmalz.glossar.txt
|
||||||
|
# Glossar glossare/krone-schmalz.glossar.txt: 7 Begriffe, 36 feste Ersetzungen
|
||||||
|
# Glossar abschriften/<Name>/<Name>.glossar.txt (Video-Glossar, automatisch): …
|
||||||
```
|
```
|
||||||
|
|
||||||
Das gemeinsame Glossar zuerst angeben, damit seine Ersetzungen zuerst greifen.
|
Das Video-Glossar wird nach den mit `-g` angegebenen angewendet; so greifen die
|
||||||
|
gemeinsamen Ersetzungen zuerst. Dasselbe gilt für `glossar_vorschlag.py` und
|
||||||
|
`aenderungen.py`. Beim Abschluss nennt `abschliessen.py` die Einträge des
|
||||||
|
Video-Glossars, die noch in keinem gemeinsamen Glossar stehen. Was auch in anderen
|
||||||
|
Videos vorkommen wird (Namen, Orte), gehört dann ins gemeinsame Glossar; der Rest
|
||||||
|
verschwindet mit dem Arbeitsordner.
|
||||||
|
|
||||||
**Ablage:** Glossare gehören ins Verzeichnis `glossare/` im Programmordner, z. B.
|
**Glossare bleiben lokal.** Sie enthalten Namen realer Personen und Hörfehler realer
|
||||||
`glossare/krone-schmalz.glossar.txt` und `glossare/ramstein.glossar.txt`. Sie
|
Beiträge und werden deshalb nicht ins Git-Repository übernommen (`glossare/`,
|
||||||
enthalten Namen realer Personen und Hörfehler realer Beiträge und werden deshalb
|
`abschriften/` und `*.glossar.txt` stehen in `.gitignore`). `glossare/` deshalb
|
||||||
nicht ins Git-Repository übernommen: `glossare/` und jede Datei `*.glossar.txt` sind
|
gesondert sichern (siehe [Abschnitt 17](#17-tipps-und-häufige-fragen)). Als Vorlage
|
||||||
über `.gitignore` ausgeschlossen und bleiben nur auf dem eigenen Rechner. Das ist
|
eignet sich das kleine Glossar zur erfundenen Beispielrede in
|
||||||
gewollt; Glossare deshalb gesondert sichern. Einzige Ausnahme ist das kleine,
|
`tests/daten/beispiel.glossar.txt`.
|
||||||
leicht nachvollziehbare Glossar zur erfundenen Beispielrede in
|
|
||||||
`tests/daten/beispiel.glossar.txt`, das sich gut als Vorlage eignet.
|
|
||||||
|
|
||||||
### 6.3 Entwurf vom Modell: `glossar_vorschlag.py`
|
### 6.3 Entwurf vom Modell: `glossar_vorschlag.py`
|
||||||
|
|
||||||
Statt den ganzen Rohtext zu lesen, kann das Modell einen ersten Entwurf liefern:
|
Statt den ganzen Rohtext zu lesen, kann das Modell einen ersten Entwurf liefern:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./glossar_vorschlag.py -i rede.txt -g glossare/krone-schmalz.glossar.txt
|
./glossar_vorschlag.py -i abschriften/<Name>/<Name>.txt -g glossare/krone-schmalz.glossar.txt
|
||||||
# → rede.glossar.entwurf.txt
|
# → abschriften/<Name>/<Name>.glossar.entwurf.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
Das Modell sucht abschnittsweise vermutete Hörfehler: Namen, Orte, Fremdwörter,
|
Das Modell sucht abschnittsweise vermutete Hörfehler: Namen, Orte, Fremdwörter,
|
||||||
|
|
@ -346,8 +390,8 @@ irrt sich oft (im Beispiel ist „Deckmäntelchen“ richtig). Eine falsche fest
|
||||||
Ersetzung verdirbt den Text unbemerkt. Deshalb:
|
Ersetzung verdirbt den Text unbemerkt. Deshalb:
|
||||||
|
|
||||||
1. Jeden Eintrag prüfen, im Zweifel im Rohtext oder Video nachsehen.
|
1. Jeden Eintrag prüfen, im Zweifel im Rohtext oder Video nachsehen.
|
||||||
2. Brauchbare Zeilen ohne `# ? ` ins Glossar des Beitrags (oder ins gemeinsame)
|
2. Brauchbare Zeilen ohne `# ? ` ins Video-Glossar
|
||||||
kopieren.
|
`abschriften/<Name>/<Name>.glossar.txt` (oder ins gemeinsame) kopieren.
|
||||||
3. Unsichere Namen lieber als **Begriff** (ohne `=>`) aufnehmen.
|
3. Unsichere Namen lieber als **Begriff** (ohne `=>`) aufnehmen.
|
||||||
|
|
||||||
Nach einem Korrekturlauf kann der Entwurf zusätzlich die Wörter auflisten, die das
|
Nach einem Korrekturlauf kann der Entwurf zusätzlich die Wörter auflisten, die das
|
||||||
|
|
@ -355,7 +399,8 @@ Modell geändert hat. Das zeigt Hörfehler, die man als feste Ersetzung absicher
|
||||||
möchte, und Fehlgriffe des Modells:
|
möchte, und Fehlgriffe des Modells:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./glossar_vorschlag.py -i rede.txt -k rede.korrigiert.txt -g glossare/krone-schmalz.glossar.txt -g glossare/rede.glossar.txt
|
./glossar_vorschlag.py -i abschriften/<Name>/<Name>.txt -k abschriften/<Name>/<Name>.korrigiert.txt \
|
||||||
|
-g glossare/krone-schmalz.glossar.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
Diese Einträge stehen in einem eigenen Abschnitt mit ihrer Anzahl, z. B.
|
Diese Einträge stehen in einem eigenen Abschnitt mit ihrer Anzahl, z. B.
|
||||||
|
|
@ -367,11 +412,12 @@ entfernen.
|
||||||
## 7. Schritt 3: Korrigieren
|
## 7. Schritt 3: Korrigieren
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./abschrift_korrigieren.py -i rede.txt -g glossare/krone-schmalz.glossar.txt -g glossare/rede.glossar.txt
|
./abschrift_korrigieren.py -i abschriften/<Name>/<Name>.txt -g glossare/krone-schmalz.glossar.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
Statt `-i rede.txt` geht auch `-i rede.de.vtt` (Untertiteldatei) oder
|
Statt des Rohtexts geht auch die Untertiteldatei (`-i …/<Name>.de-orig.vtt`) oder
|
||||||
`-u URL` (direkt von YouTube).
|
`-u URL` (direkt von YouTube). Das Ergebnis entsteht neben der Eingabe, also im
|
||||||
|
Arbeitsordner; das Video-Glossar dort wird automatisch verwendet.
|
||||||
|
|
||||||
### 7.1 Was das Programm tut
|
### 7.1 Was das Programm tut
|
||||||
|
|
||||||
|
|
@ -401,7 +447,7 @@ Ein gekürztes Beispiel mit Erläuterungen:
|
||||||
Modell: qwen3:32b
|
Modell: qwen3:32b
|
||||||
Sprache des Beitrags: de
|
Sprache des Beitrags: de
|
||||||
Glossar glossare/krone-schmalz.glossar.txt: 7 Begriffe, 36 feste Ersetzungen
|
Glossar glossare/krone-schmalz.glossar.txt: 7 Begriffe, 36 feste Ersetzungen
|
||||||
Glossar glossare/ramstein.glossar.txt: 2 Begriffe, 15 feste Ersetzungen
|
Glossar abschriften/<Name>/<Name>.glossar.txt (Video-Glossar, automatisch): 2 Begriffe, 15 feste Ersetzungen
|
||||||
Ersetzt 2× „Willly Brand“ → „Willy Brandt“ ← feste Ersetzungen mit Treffern
|
Ersetzt 2× „Willly Brand“ → „Willy Brandt“ ← feste Ersetzungen mit Treffern
|
||||||
Ersetzt 6× „Rammstein“ → „Ramstein“
|
Ersetzt 6× „Rammstein“ → „Ramstein“
|
||||||
13 Ersetzung(en) ohne Treffer: „Hostelchik“, … ← bei gemeinsamen Glossaren normal
|
13 Ersetzung(en) ohne Treffer: „Hostelchik“, … ← bei gemeinsamen Glossaren normal
|
||||||
|
|
@ -565,13 +611,60 @@ vorhandene Datei als von Hand geändert.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. Nur Ausschnitte bearbeiten (Zeitauswahl)
|
## 10. Schritt 6: Abschließen und aufräumen
|
||||||
|
|
||||||
|
Ist der Text geprüft und nachgearbeitet, wird das Video abgeschlossen. Danach zählt
|
||||||
|
nur noch die Ergebnisdatei; Rohtext, Bericht, Zwischenstände und Video-Glossar
|
||||||
|
werden nicht mehr gebraucht.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./abschliessen.py abschriften/<Name>
|
||||||
|
```
|
||||||
|
|
||||||
|
Was dabei passiert:
|
||||||
|
|
||||||
|
1. **Prüfen:** Gibt es noch `[prüfen: …]`-Marken im Text oder eine ungeklärte
|
||||||
|
`<Name>.neu.korrigiert.txt` (siehe [Abschnitt 9](#9-schritt-5-nachbessern-und-erneut-laufen-lassen)),
|
||||||
|
bricht das Programm ab und nennt die Stellen. Erst klären, dann erneut aufrufen.
|
||||||
|
Mit `--trotzdem` wird auch mit offenen Marken abgeschlossen.
|
||||||
|
2. **Übernehmen:** Jedes Ergebnis kommt nach `fertig/`, bei Ausschnitten jedes
|
||||||
|
einzeln, mit einem Quellenkopf:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Titel: Prof. Gabriele Krone-Schmalz – Respekt geht anders
|
||||||
|
Kanal: GGBLahnstein
|
||||||
|
Datum: 2026-07-30
|
||||||
|
Quelle: https://www.youtube.com/watch?v=rm6lADgf6GQ
|
||||||
|
Abgeschlossen: 2026-10-11
|
||||||
|
|
||||||
|
Vielen Dank. Liebe Gäste, …
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Angaben stammen aus `<Name>.quelle.json`, die beim Download entsteht. Fehlt
|
||||||
|
sie (z. B. bei einer eigenen `.vtt`), nennt der Kopf nur die Datei. Eine Datei in
|
||||||
|
`fertig/` mit anderem Text wird nie überschrieben.
|
||||||
|
3. **Glossar sichten:** Einträge des Video-Glossars, die in keinem Glossar unter
|
||||||
|
`glossare/` stehen, werden aufgelistet. Brauchbares jetzt ins gemeinsame Glossar
|
||||||
|
übernehmen.
|
||||||
|
4. **Aufräumen:** Der Inhalt des Arbeitsordners wird angezeigt, dann kommt die Frage
|
||||||
|
`Arbeitsordner … löschen? [j/N]`. Nur `j` löscht. Wer „n“ antwortet, kann
|
||||||
|
`abschliessen.py` später erneut aufrufen; die Datei in `fertig/` wird dann als
|
||||||
|
bereits übernommen erkannt. Mit `--ja` wird ohne Frage gelöscht.
|
||||||
|
|
||||||
|
**Schutz vor Versehen:** Nie gelöscht werden Ordner, die andere Dateien enthalten als
|
||||||
|
die, die das Programm selbst anlegt (`.txt`, `.vtt`, `.html`, `.json`, `*.parts/`),
|
||||||
|
außerdem das Aufrufverzeichnis und alles in `testdaten/`, `glossare/`, `tests/` und
|
||||||
|
`fertig/`. Das Programm nennt dann den Grund, und der Ordner bleibt stehen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Nur Ausschnitte bearbeiten (Zeitauswahl)
|
||||||
|
|
||||||
Mit `-z` wird nur ein Teil des Videos bearbeitet. Das geht mit `-u` (YouTube) und
|
Mit `-z` wird nur ein Teil des Videos bearbeitet. Das geht mit `-u` (YouTube) und
|
||||||
mit `.vtt`-Dateien, nicht mit reinem Text (der hat keine Zeitstempel). Dieselbe
|
mit `.vtt`-Dateien, nicht mit reinem Text (der hat keine Zeitstempel). Dieselbe
|
||||||
Option kennen `extract_text.py` und `vtt2txt.py`.
|
Option kennen `extract_text.py` und `vtt2txt.py`.
|
||||||
|
|
||||||
### 10.1 Zeitangaben
|
### 11.1 Zeitangaben
|
||||||
|
|
||||||
| Angabe | Bedeutung |
|
| Angabe | Bedeutung |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|
@ -584,7 +677,7 @@ Option kennen `extract_text.py` und `vtt2txt.py`.
|
||||||
> Reicht ein Bereich über das Ende des Videos hinaus, weist das Programm darauf hin
|
> Reicht ein Bereich über das Ende des Videos hinaus, weist das Programm darauf hin
|
||||||
> und nennt die Länge der Untertitel.
|
> und nennt die Länge der Untertitel.
|
||||||
|
|
||||||
### 10.2 Bereiche
|
### 11.2 Bereiche
|
||||||
|
|
||||||
| Aufruf | Bearbeitet |
|
| Aufruf | Bearbeitet |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|
@ -601,7 +694,7 @@ und werden nach der Zeit sortiert. Beispiel:
|
||||||
./abschrift_korrigieren.py -u 'https://www.youtube.com/watch?v=…' -z 00:05-00:06 00:25
|
./abschrift_korrigieren.py -u 'https://www.youtube.com/watch?v=…' -z 00:05-00:06 00:25
|
||||||
```
|
```
|
||||||
|
|
||||||
### 10.3 Was dabei herauskommt
|
### 11.3 Was dabei herauskommt
|
||||||
|
|
||||||
Alle Bereiche landen in **einer** Datei, jeder mit einer Überschrift, die seine
|
Alle Bereiche landen in **einer** Datei, jeder mit einer Überschrift, die seine
|
||||||
tatsächlichen Zeiten nennt:
|
tatsächlichen Zeiten nennt:
|
||||||
|
|
@ -625,7 +718,7 @@ Also, wenn ihr Programme baut mit Claude Code, …
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Englische Beiträge
|
## 12. Englische Beiträge
|
||||||
|
|
||||||
Mit `-u` erkennt das Programm die Sprache des Videos selbst. Bei Dateien die
|
Mit `-u` erkennt das Programm die Sprache des Videos selbst. Bei Dateien die
|
||||||
Sprache angeben:
|
Sprache angeben:
|
||||||
|
|
@ -646,28 +739,44 @@ Andere Sprachen lehnt das Programm mit einer Meldung ab.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 12. Dateien, die entstehen
|
## 13. Dateien, die entstehen
|
||||||
|
|
||||||
### 12.1 Übersicht
|
### 13.1 Übersicht
|
||||||
|
|
||||||
Beispiel für `-i rede.txt` bzw. `-o rede.korrigiert.txt`:
|
Im Programmordner entstehen diese Verzeichnisse. Alle bleiben nur auf dem eigenen
|
||||||
|
Rechner, keines geht ins Git-Repository:
|
||||||
|
|
||||||
|
```text
|
||||||
|
glossare/ gemeinsame Glossare je Sprecher oder Kanal
|
||||||
|
abschriften/<Name>/ Arbeitsordner, einer je Video
|
||||||
|
fertig/ abgeschlossene Abschriften, je eine Datei
|
||||||
|
testdaten/<rede>/ nur für Entwickler: echte Reden für Regressionstests
|
||||||
|
```
|
||||||
|
|
||||||
|
Im Arbeitsordner `abschriften/<Name>/`:
|
||||||
|
|
||||||
| Datei | Inhalt | Wird überschrieben? |
|
| Datei | Inhalt | Wird überschrieben? |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `rede.txt` | Rohtext (bei `-u` automatisch gespeichert) | nie, wenn sie schon mit anderem Inhalt existiert (dann Warnung) |
|
| `<Name>.txt` | Rohtext (bei `-u` automatisch gespeichert) | nie, wenn sie schon mit anderem Inhalt existiert (dann Warnung) |
|
||||||
| `rede.korrigiert.txt` | korrigierte Abschrift | ja, außer sie wurde von Hand geändert (dann `rede.neu.korrigiert.txt`, siehe [Abschnitt 9](#9-schritt-5-nachbessern-und-erneut-laufen-lassen)) |
|
| `<Name>.quelle.json` | Titel, Kanal, Datum, URL (beim Download) | ja, beim nächsten Download |
|
||||||
| `rede.aenderungen.html` | Änderungsbericht | ja, bei jedem Lauf |
|
| `<Name>.glossar.txt` | Video-Glossar (legt man selbst an) | nie |
|
||||||
| `rede.korrigiert.parts/` | Zwischenstände | wird ergänzt; darf jederzeit gelöscht werden |
|
| `<Name>.korrigiert.txt` | korrigierte Abschrift | ja, außer sie wurde von Hand geändert (dann `<Name>.neu.korrigiert.txt`, siehe [Abschnitt 9](#9-schritt-5-nachbessern-und-erneut-laufen-lassen)) |
|
||||||
| `rede.glossar.entwurf.txt` | Glossar-Entwurf von `glossar_vorschlag.py` | ja |
|
| `<Name>.aenderungen.html` | Änderungsbericht | ja, bei jedem Lauf |
|
||||||
| `rede.glossar.entwurf.parts/` | Zwischenstände des Entwurfs | wird ergänzt |
|
| `<Name>.korrigiert.parts/` | Zwischenstände | wird ergänzt; darf jederzeit gelöscht werden |
|
||||||
|
| `<Name>.glossar.entwurf.txt` | Glossar-Entwurf von `glossar_vorschlag.py` | ja |
|
||||||
|
| `<Name>.glossar.entwurf.parts/` | Zwischenstände des Entwurfs | wird ergänzt |
|
||||||
|
|
||||||
Ohne `-o` liegt das Ergebnis neben der Eingabedatei. Bei `-u` ohne `-o` im
|
Ausschnitte (`-z`) desselben Videos landen im selben Ordner, mit den Bereichen im
|
||||||
aktuellen Verzeichnis. Fehlende Verzeichnisse in `-o` werden angelegt.
|
Namen. Mit `abschliessen.py` kommt jedes Ergebnis als `fertig/<Name>.txt` heraus, und
|
||||||
|
der Arbeitsordner wird gelöscht ([Abschnitt 10](#10-schritt-6-abschließen-und-aufräumen)).
|
||||||
|
|
||||||
### 12.2 Standard-Dateinamen
|
Mit `-i` liegt das Ergebnis neben der Eingabedatei; mit `-o` dort, wo man will
|
||||||
|
(fehlende Verzeichnisse werden angelegt).
|
||||||
|
|
||||||
Ohne `-o` (bzw. `-n` bei `yt_untertitel.py`) entsteht der Name aus **Kanal, Titel
|
### 13.2 Standard-Dateinamen
|
||||||
und Hochladedatum**:
|
|
||||||
|
Ohne `-o` (bzw. `-n` bei `yt_untertitel.py`) entsteht der Name des Arbeitsordners
|
||||||
|
und der Dateien aus **Kanal, Titel und Hochladedatum**:
|
||||||
|
|
||||||
| Video | Name |
|
| Video | Name |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|
@ -680,7 +789,7 @@ denselben Namen.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 13. Zwischenstände, Abbruch und Fortsetzen
|
## 14. Zwischenstände, Abbruch und Fortsetzen
|
||||||
|
|
||||||
Jeder fertig korrigierte Abschnitt, jede Absatzgliederung und jede Entscheidung
|
Jeder fertig korrigierte Abschnitt, jede Absatzgliederung und jede Entscheidung
|
||||||
beim Verbinden kurzer Absätze wird sofort in `<Ausgabe>.parts/` gespeichert.
|
beim Verbinden kurzer Absätze wird sofort in `<Ausgabe>.parts/` gespeichert.
|
||||||
|
|
@ -691,11 +800,12 @@ beim Verbinden kurzer Absätze wird sofort in `<Ausgabe>.parts/` gespeichert.
|
||||||
- Ändern sich Modell, Anweisung, Glossar oder der Text eines Abschnitts, wird genau
|
- Ändern sich Modell, Anweisung, Glossar oder der Text eines Abschnitts, wird genau
|
||||||
dieser Teil neu berechnet; alte Zwischenstände stören nicht.
|
dieser Teil neu berechnet; alte Zwischenstände stören nicht.
|
||||||
- Der Ordner `*.parts/` darf jederzeit gelöscht werden; dann wird alles neu
|
- Der Ordner `*.parts/` darf jederzeit gelöscht werden; dann wird alles neu
|
||||||
berechnet.
|
berechnet. Er wächst mit jeder Modell- oder Glossaränderung, weil alte Fassungen
|
||||||
|
liegen bleiben. Beim Abschluss verschwindet er mit dem Arbeitsordner.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 14. Ein anderes Sprachmodell verwenden
|
## 15. Ein anderes Sprachmodell verwenden
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ollama pull qwen3:14b
|
ollama pull qwen3:14b
|
||||||
|
|
@ -713,7 +823,7 @@ Weitere Einstellungen (Abschnittsgröße, Absatzlängen, Schwellen) sind in der
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 15. Fehlermeldungen und Lösungen
|
## 16. Fehlermeldungen und Lösungen
|
||||||
|
|
||||||
| Meldung | Ursache und Lösung |
|
| Meldung | Ursache und Lösung |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|
@ -725,7 +835,7 @@ Weitere Einstellungen (Abschnittsgröße, Absatzlängen, Schwellen) sind in der
|
||||||
| `Keine Untertitel in „de“ (weder manuell noch automatisch in Originalsprache).` | Das Video hat keine passende Spur. Die Meldung listet die vorhandenen Sprachen; ggf. mit `-l` eine davon wählen. Ist das Video z. B. englisch, `-l en` angeben. |
|
| `Keine Untertitel in „de“ (weder manuell noch automatisch in Originalsprache).` | Das Video hat keine passende Spur. Die Meldung listet die vorhandenen Sprachen; ggf. mit `-l` eine davon wählen. Ist das Video z. B. englisch, `-l en` angeben. |
|
||||||
| `Der Beitrag ist auf „fr“; unterstützt: de, en (-l).` | Nur Deutsch und Englisch werden korrigiert. |
|
| `Der Beitrag ist auf „fr“; unterstützt: de, en (-l).` | Nur Deutsch und Englisch werden korrigiert. |
|
||||||
| `-z braucht Zeitstempel: nur mit .vtt-Eingabe oder -u.` | Zeitauswahl geht nicht mit `.txt`. Die `.vtt`-Datei oder `-u URL` verwenden. |
|
| `-z braucht Zeitstempel: nur mit .vtt-Eingabe oder -u.` | Zeitauswahl geht nicht mit `.txt`. Die `.vtt`-Datei oder `-u URL` verwenden. |
|
||||||
| `-z/--zeitraeume: …` | Zeitangabe ungültig oder Bereiche überschneiden sich, siehe [Abschnitt 10](#10-nur-ausschnitte-bearbeiten-zeitauswahl). |
|
| `-z/--zeitraeume: …` | Zeitangabe ungültig oder Bereiche überschneiden sich, siehe [Abschnitt 11](#11-nur-ausschnitte-bearbeiten-zeitauswahl). |
|
||||||
| `Die Eingabedatei enthält im gewählten Zeitraum keinen Text.` | Im Bereich gibt es keine Untertitel. Zeiten prüfen (`05:00` = 5 Stunden!). |
|
| `Die Eingabedatei enthält im gewählten Zeitraum keinen Text.` | Im Bereich gibt es keine Untertitel. Zeiten prüfen (`05:00` = 5 Stunden!). |
|
||||||
| `Die Eingabedatei ist leer.` | Datei prüfen. |
|
| `Die Eingabedatei ist leer.` | Datei prüfen. |
|
||||||
| `Ausgabedatei darf nicht die Eingabedatei sein.` | Mit `-o` einen anderen Namen wählen. |
|
| `Ausgabedatei darf nicht die Eingabedatei sein.` | Mit `-o` einen anderen Namen wählen. |
|
||||||
|
|
@ -738,12 +848,16 @@ Weitere Einstellungen (Abschnittsgröße, Absatzlängen, Schwellen) sind in der
|
||||||
| `Hinweis: keine Wortliste für „de“ (Paket wngerman); …` | `sudo apt install wngerman` (für Englisch `wamerican`). Ohne Wortliste fehlt im Bericht nur der Grund „gültiges Wort ersetzt“. |
|
| `Hinweis: keine Wortliste für „de“ (Paket wngerman); …` | `sudo apt install wngerman` (für Englisch `wamerican`). Ohne Wortliste fehlt im Bericht nur der Grund „gültiges Wort ersetzt“. |
|
||||||
| `Hinweis: keine Untertitel im Bereich …` | Einer von mehreren Bereichen ist leer und wird übersprungen. In Klammern steht, wo die Untertitel enden. |
|
| `Hinweis: keine Untertitel im Bereich …` | Einer von mehreren Bereichen ist leer und wird übersprungen. In Klammern steht, wo die Untertitel enden. |
|
||||||
| `Hinweis: Bereich Anfang – 05:00:00 reicht über das Ende der Untertitel hinaus (…)` | Der Bereich endet nach dem Video. Meist war Minute statt Stunde gemeint: `05:00` ist Stunde 5, Minute 5 ist `00:05`. Bearbeitet wird trotzdem, bis zum Ende des Videos. |
|
| `Hinweis: Bereich Anfang – 05:00:00 reicht über das Ende der Untertitel hinaus (…)` | Der Bereich endet nach dem Video. Meist war Minute statt Stunde gemeint: `05:00` ist Stunde 5, Minute 5 ist `00:05`. Bearbeitet wird trotzdem, bis zum Ende des Videos. |
|
||||||
|
| `Noch offene Prüfmarken. Erst klären, oder mit --trotzdem abschließen.` | `abschliessen.py`: Im Text stehen noch `[prüfen: …]`-Marken (die Zeilen werden genannt). Auflösen und erneut aufrufen. |
|
||||||
|
| `Ungeklärter Konflikt: <Name>.neu.korrigiert.txt …` | `abschliessen.py`: Erst die eigenen Änderungen übertragen bzw. entscheiden, welche Fassung gilt, und die `.neu`-Datei löschen. |
|
||||||
|
| `Gibt es schon in fertig mit anderem Text: …` | In `fertig/` liegt schon eine Abschrift dieses Namens mit anderem Inhalt. Sie wird nie überschrieben: umbenennen oder entfernen, dann erneut aufrufen. |
|
||||||
|
| `Arbeitsordner … bleibt erhalten: Fremde Datei: …` | Im Arbeitsordner liegt eine Datei, die das Programm nicht selbst anlegt (z. B. Notizen). Die fertige Abschrift ist trotzdem in `fertig/`; den Ordner selbst aufräumen. |
|
||||||
| `Keine Berechtigung` beim Start mit `./…` | `chmod +x *.py` oder `python3 programm.py …` aufrufen. |
|
| `Keine Berechtigung` beim Start mit `./…` | `chmod +x *.py` oder `python3 programm.py …` aufrufen. |
|
||||||
| Lauf ist extrem langsam | Das Modell passt nicht in den Grafikspeicher. `OLLAMA_NUM_PARALLEL=1` setzen (siehe [Abschnitt 2.2](#22-grafikkarte)), andere Programme mit Grafikspeicherbedarf schließen oder ein kleineres Modell wählen. Prüfen mit `ollama ps` (Spalte PROCESSOR sollte „100% GPU“ zeigen). |
|
| Lauf ist extrem langsam | Das Modell passt nicht in den Grafikspeicher. `OLLAMA_NUM_PARALLEL=1` setzen (siehe [Abschnitt 2.2](#22-grafikkarte)), andere Programme mit Grafikspeicherbedarf schließen oder ein kleineres Modell wählen. Prüfen mit `ollama ps` (Spalte PROCESSOR sollte „100% GPU“ zeigen). |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 16. Tipps und häufige Fragen
|
## 17. Tipps und häufige Fragen
|
||||||
|
|
||||||
**Wie bekomme ich die besten Ergebnisse?**
|
**Wie bekomme ich die besten Ergebnisse?**
|
||||||
Mit einem guten Glossar. Das Modell kennt viele Namen nicht oder schreibt sie
|
Mit einem guten Glossar. Das Modell kennt viele Namen nicht oder schreibt sie
|
||||||
|
|
@ -777,17 +891,28 @@ for url in 'https://www.youtube.com/watch?v=AAA' 'https://www.youtube.com/watch?
|
||||||
done
|
done
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Wie sichere ich meine Daten?**
|
||||||
|
Wertvoll sind auf Dauer nur `glossare/` (gewachsenes Wissen über Namen und Hörfehler)
|
||||||
|
und `fertig/` (die Ergebnisse). Beide sind bewusst nicht im öffentlichen
|
||||||
|
Git-Repository. Am einfachsten ist ein zweites, **privates** Git-Repository nur für
|
||||||
|
diese beiden Ordner, oder man nimmt sie ins normale Backup auf. `abschriften/` muss
|
||||||
|
nicht gesichert werden; was dort liegt, ist entweder in Arbeit oder lässt sich neu
|
||||||
|
erzeugen.
|
||||||
|
|
||||||
|
**Welche Videos sind noch in Arbeit?**
|
||||||
|
Alle, die einen Ordner in `abschriften/` haben: `ls abschriften/`.
|
||||||
|
|
||||||
**Wo sind die Absätze?**
|
**Wo sind die Absätze?**
|
||||||
In `*.korrigiert.txt` trennt eine Leerzeile die Absätze. Viele Textprogramme
|
In `*.korrigiert.txt` trennt eine Leerzeile die Absätze. Viele Textprogramme
|
||||||
übernehmen das beim Einfügen direkt.
|
übernehmen das beim Einfügen direkt.
|
||||||
|
|
||||||
**Kann ich das Ergebnis weiterverarbeiten (Word, LibreOffice)?**
|
**Kann ich das Ergebnis weiterverarbeiten (Word, LibreOffice)?**
|
||||||
Die `.korrigiert.txt` ist reiner UTF-8-Text und lässt sich überall öffnen oder
|
Die Dateien in `fertig/` (und die `.korrigiert.txt`) sind reiner UTF-8-Text und
|
||||||
einfügen.
|
lassen sich überall öffnen oder einfügen.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 17. Kurzreferenz aller Befehle
|
## 18. Kurzreferenz aller Befehle
|
||||||
|
|
||||||
### `abschrift_korrigieren.py`
|
### `abschrift_korrigieren.py`
|
||||||
|
|
||||||
|
|
@ -795,7 +920,7 @@ einfügen.
|
||||||
|---|---|
|
|---|---|
|
||||||
| `-i DATEI` | Eingabe: Rohtext (`.txt`) oder Untertitel (`.vtt`) |
|
| `-i DATEI` | Eingabe: Rohtext (`.txt`) oder Untertitel (`.vtt`) |
|
||||||
| `-u URL` | statt `-i`: YouTube-Video laden und korrigieren |
|
| `-u URL` | statt `-i`: YouTube-Video laden und korrigieren |
|
||||||
| `-o DATEI` | Ausgabedatei (Standard: `<Eingabe>.korrigiert.txt` bzw. `<Kanal>_<Titel>_<Datum>.korrigiert.txt`) |
|
| `-o DATEI` | Ausgabedatei (Standard: `<Eingabe>.korrigiert.txt`, mit `-u` `abschriften/<Name>/<Name>.korrigiert.txt`) |
|
||||||
| `-g DATEI` | Glossar, mehrfach möglich (Reihenfolge zählt) |
|
| `-g DATEI` | Glossar, mehrfach möglich (Reihenfolge zählt) |
|
||||||
| `-l SPRACHE` | `de` oder `en` (Standard: `de`, mit `-u` die Originalsprache) |
|
| `-l SPRACHE` | `de` oder `en` (Standard: `de`, mit `-u` die Originalsprache) |
|
||||||
| `-m MODELL` | Ollama-Modell (Standard: `qwen3:32b`) |
|
| `-m MODELL` | Ollama-Modell (Standard: `qwen3:32b`) |
|
||||||
|
|
@ -809,7 +934,7 @@ einfügen.
|
||||||
| Option | Bedeutung |
|
| Option | Bedeutung |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `-u URL` | YouTube-URL (Pflicht) |
|
| `-u URL` | YouTube-URL (Pflicht) |
|
||||||
| `-o DATEI` | Ausgabedatei (Standard: `<Kanal>_<Titel>_<Datum>.txt`) |
|
| `-o DATEI` | Ausgabedatei (Standard: `abschriften/<Name>/<Name>.txt`) |
|
||||||
| `-l SPRACHE` | Sprache der Untertitel (Standard: Originalsprache) |
|
| `-l SPRACHE` | Sprache der Untertitel (Standard: Originalsprache) |
|
||||||
| `-f` | Fließtext mit 80 Zeichen pro Zeile |
|
| `-f` | Fließtext mit 80 Zeichen pro Zeile |
|
||||||
| `-z BEREICH …`, `--exakt` | Zeitauswahl wie oben |
|
| `-z BEREICH …`, `--exakt` | Zeitauswahl wie oben |
|
||||||
|
|
@ -819,9 +944,9 @@ einfügen.
|
||||||
| Option | Bedeutung |
|
| Option | Bedeutung |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `-u URL` | YouTube-URL (Pflicht) |
|
| `-u URL` | YouTube-URL (Pflicht) |
|
||||||
| `-n NAME` | Dateiname ohne Endung (Standard: `<Kanal>_<Titel>_<Datum>`) |
|
| `-n NAME` | Name von Datei und Arbeitsordner (Standard: `<Kanal>_<Titel>_<Datum>`) |
|
||||||
| `-l SPRACHE` | Sprache (Standard: `de`) |
|
| `-l SPRACHE` | Sprache (Standard: `de`) |
|
||||||
| `-d VERZEICHNIS` | Zielverzeichnis (Standard: aktuelles) |
|
| `-d VERZEICHNIS` | Zielverzeichnis (Standard: `abschriften/<Name>/`) |
|
||||||
|
|
||||||
### `vtt2txt.py`
|
### `vtt2txt.py`
|
||||||
|
|
||||||
|
|
@ -851,4 +976,16 @@ einfügen.
|
||||||
| `-g DATEI` | Glossare der Korrektur, mehrfach möglich |
|
| `-g DATEI` | Glossare der Korrektur, mehrfach möglich |
|
||||||
| `-l SPRACHE` | Sprache für die Wortliste (Standard: `de`) |
|
| `-l SPRACHE` | Sprache für die Wortliste (Standard: `de`) |
|
||||||
|
|
||||||
|
### `abschliessen.py`
|
||||||
|
|
||||||
|
| Option | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| `ORDNER` | Arbeitsordner, z. B. `abschriften/<Name>` (Pflicht) |
|
||||||
|
| `--ziel VERZEICHNIS` | für fertige Abschriften (Standard: `fertig`) |
|
||||||
|
| `--glossare VERZEICHNIS` | gemeinsame Glossare für den Glossar-Hinweis (Standard: `glossare`) |
|
||||||
|
| `--ja` | Arbeitsordner ohne Rückfrage löschen |
|
||||||
|
| `--trotzdem` | auch mit offenen Prüfmarken abschließen |
|
||||||
|
|
||||||
|
Das Video-Glossar `abschriften/<Name>/<Name>.glossar.txt` verwenden
|
||||||
|
`abschrift_korrigieren.py`, `glossar_vorschlag.py` und `aenderungen.py` automatisch.
|
||||||
Jedes Programm zeigt mit `--help` seine Optionen.
|
Jedes Programm zeigt mit `--help` seine Optionen.
|
||||||
|
|
|
||||||
193
README.md
193
README.md
|
|
@ -21,6 +21,7 @@ Die Werkzeuge sind einzeln oder hintereinander nutzbar:
|
||||||
| `abschrift_korrigieren.py` | Rohabschrift (oder VTT direkt) mit dem Sprachmodell korrigieren |
|
| `abschrift_korrigieren.py` | Rohabschrift (oder VTT direkt) mit dem Sprachmodell korrigieren |
|
||||||
| `aenderungen.py` | Änderungsbericht: was hat das Modell gegenüber dem Rohtext geändert? |
|
| `aenderungen.py` | Änderungsbericht: was hat das Modell gegenüber dem Rohtext geändert? |
|
||||||
| `glossar_vorschlag.py` | Glossar-Entwurf: vermutete Hörfehler bei Namen und Begriffen |
|
| `glossar_vorschlag.py` | Glossar-Entwurf: vermutete Hörfehler bei Namen und Begriffen |
|
||||||
|
| `abschliessen.py` | fertige Abschrift mit Quellenangabe nach `fertig/`, Arbeitsordner löschen |
|
||||||
|
|
||||||
## Voraussetzungen
|
## Voraussetzungen
|
||||||
|
|
||||||
|
|
@ -41,26 +42,31 @@ der Lauf wird etwa 12-mal langsamer.
|
||||||
|
|
||||||
## Aufruf
|
## Aufruf
|
||||||
|
|
||||||
Von der YouTube-URL zum korrigierten Text in einem Befehl:
|
Von der YouTube-URL zum korrigierten Text in einem Befehl, dann abschließen:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./abschrift_korrigieren.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -o lahnstein.korrigiert.txt
|
./abschrift_korrigieren.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -g glossare/krone-schmalz.glossar.txt
|
||||||
# → lahnstein.korrigiert.txt, lahnstein.txt (Rohtext), lahnstein.aenderungen.html
|
# → abschriften/<Name>/: <Name>.txt (Rohtext), <Name>.quelle.json,
|
||||||
|
# <Name>.korrigiert.txt, <Name>.aenderungen.html, <Name>.korrigiert.parts/
|
||||||
|
./abschliessen.py abschriften/<Name> # → fertig/<Name>.txt, Arbeitsordner nach Rückfrage gelöscht
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`<Name>` ist `<Kanal>_<Titel>_<Datum>`, siehe [Dateiablage](#dateiablage).
|
||||||
|
|
||||||
Oder in zwei Schritten, um den Rohtext vorher anzusehen:
|
Oder in zwei Schritten, um den Rohtext vorher anzusehen:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./extract_text.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -o lahnstein.txt
|
./extract_text.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' # → abschriften/<Name>/<Name>.txt
|
||||||
./abschrift_korrigieren.py -i lahnstein.txt # → lahnstein.korrigiert.txt
|
./abschrift_korrigieren.py -i abschriften/<Name>/<Name>.txt # Ergebnis im selben Ordner
|
||||||
```
|
```
|
||||||
|
|
||||||
Dasselbe in Einzelschritten, wenn die VTT-Datei erhalten bleiben soll:
|
Dasselbe in Einzelschritten, wenn die VTT-Datei erhalten bleiben soll:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./yt_untertitel.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -n Lahnstein -d untertitel
|
./yt_untertitel.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -n Lahnstein
|
||||||
./vtt2txt.py -i untertitel/Lahnstein.de-orig.vtt
|
# → abschriften/Lahnstein/Lahnstein.de-orig.vtt
|
||||||
./abschrift_korrigieren.py -i untertitel/Lahnstein.de-orig.txt
|
./vtt2txt.py -i abschriften/Lahnstein/Lahnstein.de-orig.vtt
|
||||||
|
./abschrift_korrigieren.py -i abschriften/Lahnstein/Lahnstein.de-orig.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
Der Schritt über `vtt2txt.py` ist optional: `abschrift_korrigieren.py` nimmt auch die
|
Der Schritt über `vtt2txt.py` ist optional: `abschrift_korrigieren.py` nimmt auch die
|
||||||
|
|
@ -75,7 +81,7 @@ Zwischenschritt im temporären Verzeichnis. Die Spurwahl ist dieselbe wie bei
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./extract_text.py -u 'https://www.youtube.com/watch?v=0LmCveTM788'
|
./extract_text.py -u 'https://www.youtube.com/watch?v=0LmCveTM788'
|
||||||
# → John_Mearsheimer_Offensive_Realism_Episode_1_Origins_of_the_War_In_Ukraine_2026-07-30.txt
|
# → abschriften/John_Mearsheimer_…_2026-07-30/John_Mearsheimer_…_2026-07-30.txt (+ .quelle.json)
|
||||||
./extract_text.py -u URL -l de -o texte/rede.txt # Sprache und Ziel
|
./extract_text.py -u URL -l de -o texte/rede.txt # Sprache und Ziel
|
||||||
./extract_text.py -u URL -f -o rede.txt # lesbarer Fließtext
|
./extract_text.py -u URL -f -o rede.txt # lesbarer Fließtext
|
||||||
./extract_text.py -u URL -z 0:03:17-0:05:25 # nur ein Ausschnitt
|
./extract_text.py -u URL -z 0:03:17-0:05:25 # nur ein Ausschnitt
|
||||||
|
|
@ -87,7 +93,7 @@ Zwischenschritt im temporären Verzeichnis. Die Spurwahl ist dieselbe wie bei
|
||||||
|---|---|
|
|---|---|
|
||||||
| `-u`, `--url` | YouTube-URL, Pflicht |
|
| `-u`, `--url` | YouTube-URL, Pflicht |
|
||||||
| `-l`, `--lang` | Sprache; Standard: Sprache des Original-Audios (`<sprache>-orig`) |
|
| `-l`, `--lang` | Sprache; Standard: Sprache des Original-Audios (`<sprache>-orig`) |
|
||||||
| `-o`, `--output` | Ausgabedatei (Verzeichnisse werden angelegt, vorhandene Datei überschrieben); Standard: `<Kanal>_<Titel>_<Datum>.txt`, siehe unten |
|
| `-o`, `--output` | Ausgabedatei (Verzeichnisse werden angelegt, vorhandene Datei überschrieben); Standard: `abschriften/<Name>/<Name>.txt`, siehe unten |
|
||||||
| `-z`, `--zeitraeume` | nur Untertitel in diesen Bereichen, siehe [Zeitauswahl](#zeitauswahl--z) |
|
| `-z`, `--zeitraeume` | nur Untertitel in diesen Bereichen, siehe [Zeitauswahl](#zeitauswahl--z) |
|
||||||
| `--exakt` | genau an den Zeiten schneiden statt an Satzgrenzen |
|
| `--exakt` | genau an den Zeiten schneiden statt an Satzgrenzen |
|
||||||
| `-f`, `--fliesstext` | Fließtext, auf 80 Zeichen umbrochen (Wörter werden nie getrennt), statt der kurzen Untertitelzeilen; beides versteht `abschrift_korrigieren.py` |
|
| `-f`, `--fliesstext` | Fließtext, auf 80 Zeichen umbrochen (Wörter werden nie getrennt), statt der kurzen Untertitelzeilen; beides versteht `abschrift_korrigieren.py` |
|
||||||
|
|
@ -95,7 +101,8 @@ Zwischenschritt im temporären Verzeichnis. Die Spurwahl ist dieselbe wie bei
|
||||||
### Standard-Dateinamen für YouTube-Videos
|
### Standard-Dateinamen für YouTube-Videos
|
||||||
|
|
||||||
Ohne `-o` bzw. `-n` bilden `extract_text.py`, `yt_untertitel.py` und
|
Ohne `-o` bzw. `-n` bilden `extract_text.py`, `yt_untertitel.py` und
|
||||||
`abschrift_korrigieren.py -u` den Namen aus Kanal, Titel und Hochladedatum:
|
`abschrift_korrigieren.py -u` den Namen aus Kanal, Titel und Hochladedatum und
|
||||||
|
schreiben in den Arbeitsordner `abschriften/<Name>/`:
|
||||||
|
|
||||||
| Video | Name |
|
| Video | Name |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|
@ -108,8 +115,11 @@ Wörter werden mit `_` verbunden. Bindestriche und Punkte im Wort bleiben
|
||||||
Video-ID. Mit `-z` kommen die Bereiche dazu, z. B.
|
Video-ID. Mit `-z` kommen die Bereiche dazu, z. B.
|
||||||
`…_2026-07-30_00-03-17_bis_00-05-25` (`-z A`: `_ab_…`, `-z 0-B` bzw. `-z -B`: `_bis_…`;
|
`…_2026-07-30_00-03-17_bis_00-05-25` (`-z A`: `_ab_…`, `-z 0-B` bzw. `-z -B`: `_bis_…`;
|
||||||
mehrere Bereiche mit `+` verbunden: `…_00-05-00_bis_00-06-00+ab_00-25-00`).
|
mehrere Bereiche mit `+` verbunden: `…_00-05-00_bis_00-06-00+ab_00-25-00`).
|
||||||
Gleiches Video ergibt immer denselben Namen; eine vorhandene Datei wird
|
Gleiches Video ergibt immer denselben Namen und denselben Ordner, Ausschnitte
|
||||||
überschrieben (ein mit `-u` gespeicherter, abweichender Rohtext aber nie).
|
eines Videos landen also beisammen. Eine vorhandene Datei wird überschrieben (ein mit
|
||||||
|
`-u` gespeicherter, abweichender Rohtext und eine von Hand geänderte Korrektur aber
|
||||||
|
nie). Neben dem Rohtext entsteht `<Rohtext>.quelle.json` mit Titel, Kanal, Datum und
|
||||||
|
URL; daraus macht `abschliessen.py` den Quellenkopf.
|
||||||
|
|
||||||
### Untertitel herunterladen: `yt_untertitel.py`
|
### Untertitel herunterladen: `yt_untertitel.py`
|
||||||
|
|
||||||
|
|
@ -130,7 +140,8 @@ gehören zur Synchronisation und werden nicht genommen. Sprachen werden auch mit
|
||||||
Region gefunden: `-l en` findet manuelle Untertitel `en-US`.
|
Region gefunden: `-l en` findet manuelle Untertitel `en-US`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./yt_untertitel.py -u URL # → <Kanal>_<Titel>_<Datum>.de-orig.vtt
|
./yt_untertitel.py -u URL # → abschriften/<Name>/<Name>.de-orig.vtt
|
||||||
|
./yt_untertitel.py -u URL -n Lahnstein # → abschriften/Lahnstein/Lahnstein.de-orig.vtt
|
||||||
./yt_untertitel.py -u URL -n Lahnstein -d untertitel # → untertitel/Lahnstein.de-orig.vtt
|
./yt_untertitel.py -u URL -n Lahnstein -d untertitel # → untertitel/Lahnstein.de-orig.vtt
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -139,7 +150,7 @@ Region gefunden: `-l en` findet manuelle Untertitel `en-US`.
|
||||||
| `-u`, `--url` | YouTube-URL, Pflicht |
|
| `-u`, `--url` | YouTube-URL, Pflicht |
|
||||||
| `-n`, `--name` | Dateiname ohne Endung; Standard: `<Kanal>_<Titel>_<Datum>` |
|
| `-n`, `--name` | Dateiname ohne Endung; Standard: `<Kanal>_<Titel>_<Datum>` |
|
||||||
| `-l`, `--lang` | Sprache; Standard: `de` |
|
| `-l`, `--lang` | Sprache; Standard: `de` |
|
||||||
| `-d`, `--dir` | Zielverzeichnis (wird angelegt); Standard: aktuelles Verzeichnis |
|
| `-d`, `--dir` | Zielverzeichnis (wird angelegt); Standard: Arbeitsordner `abschriften/<Name>/` |
|
||||||
|
|
||||||
Eine vorhandene Datei gleichen Namens wird überschrieben.
|
Eine vorhandene Datei gleichen Namens wird überschrieben.
|
||||||
|
|
||||||
|
|
@ -232,7 +243,7 @@ werden trotzdem umgewandelt; der Exit-Code ist dann 1.
|
||||||
./abschrift_korrigieren.py -i rede.txt -g glossare/rede.glossar.txt # mit Glossar
|
./abschrift_korrigieren.py -i rede.txt -g glossare/rede.glossar.txt # mit Glossar
|
||||||
./abschrift_korrigieren.py -i rede.txt -g glossare/allgemein.glossar.txt -g glossare/rede.glossar.txt # kombiniert
|
./abschrift_korrigieren.py -i rede.txt -g glossare/allgemein.glossar.txt -g glossare/rede.glossar.txt # kombiniert
|
||||||
./abschrift_korrigieren.py -i rede.de.vtt # VTT direkt → rede.de.korrigiert.txt
|
./abschrift_korrigieren.py -i rede.de.vtt # VTT direkt → rede.de.korrigiert.txt
|
||||||
./abschrift_korrigieren.py -u URL # YouTube → <Kanal>_<Titel>_<Datum>.korrigiert.txt
|
./abschrift_korrigieren.py -u URL # YouTube → abschriften/<Name>/<Name>.korrigiert.txt
|
||||||
./abschrift_korrigieren.py -i talk.txt -l en # englischer Beitrag
|
./abschrift_korrigieren.py -i talk.txt -l en # englischer Beitrag
|
||||||
./abschrift_korrigieren.py -u URL -z 0:03:17-0:05:25 # nur ein Ausschnitt des Videos
|
./abschrift_korrigieren.py -u URL -z 0:03:17-0:05:25 # nur ein Ausschnitt des Videos
|
||||||
./abschrift_korrigieren.py -u URL -z 00:05-00:06 00:25 # mehrere Bereiche, eine Datei
|
./abschrift_korrigieren.py -u URL -z 00:05-00:06 00:25 # mehrere Bereiche, eine Datei
|
||||||
|
|
@ -243,10 +254,10 @@ werden trotzdem umgewandelt; der Exit-Code ist dann 1.
|
||||||
| `-i`, `--input` | Eingabedatei: Rohabschrift (`.txt`) oder Untertitel (`.vtt`, wird wie mit `vtt2txt.py` umgewandelt) |
|
| `-i`, `--input` | Eingabedatei: Rohabschrift (`.txt`) oder Untertitel (`.vtt`, wird wie mit `vtt2txt.py` umgewandelt) |
|
||||||
| `-u`, `--url` | statt `-i`: YouTube-URL; Untertitel wie bei `extract_text.py`. Der Rohtext wird neben der Ausgabe gespeichert (`rede.korrigiert.txt` → `rede.txt`, sonst `<Name>.roh.txt`), eine vorhandene, abweichende Datei aber nie überschrieben |
|
| `-u`, `--url` | statt `-i`: YouTube-URL; Untertitel wie bei `extract_text.py`. Der Rohtext wird neben der Ausgabe gespeichert (`rede.korrigiert.txt` → `rede.txt`, sonst `<Name>.roh.txt`), eine vorhandene, abweichende Datei aber nie überschrieben |
|
||||||
| `-l`, `--lang` | Sprache des Beitrags: `de` oder `en` (auch mit Region, z. B. `en-US`); wählt Prompts und Prüfmarke. Mit `-u` zugleich die Sprache der Untertitel. Standard: `de`, mit `-u` die Sprache des Original-Audios |
|
| `-l`, `--lang` | Sprache des Beitrags: `de` oder `en` (auch mit Region, z. B. `en-US`); wählt Prompts und Prüfmarke. Mit `-u` zugleich die Sprache der Untertitel. Standard: `de`, mit `-u` die Sprache des Original-Audios |
|
||||||
| `-o`, `--output` | Ausgabedatei; Standard: `<Eingabe ohne Endung>.korrigiert.txt` neben der Eingabe, mit `-u` `<Kanal>_<Titel>_<Datum>.korrigiert.txt`; mit `-z` zusätzlich die Bereiche im Namen |
|
| `-o`, `--output` | Ausgabedatei; Standard: `<Eingabe ohne Endung>.korrigiert.txt` neben der Eingabe, mit `-u` `abschriften/<Name>/<Name>.korrigiert.txt`; mit `-z` zusätzlich die Bereiche im Namen |
|
||||||
| `-z`, `--zeitraeume` | nur diese Bereiche korrigieren, siehe [Zeitauswahl](#zeitauswahl--z); nur mit `.vtt`-Eingabe oder `-u`, da reiner Text keine Zeitstempel hat |
|
| `-z`, `--zeitraeume` | nur diese Bereiche korrigieren, siehe [Zeitauswahl](#zeitauswahl--z); nur mit `.vtt`-Eingabe oder `-u`, da reiner Text keine Zeitstempel hat |
|
||||||
| `--exakt` | genau an den Zeiten schneiden statt an Satzgrenzen |
|
| `--exakt` | genau an den Zeiten schneiden statt an Satzgrenzen |
|
||||||
| `-g`, `--glossar` | Glossar mit festen Ersetzungen und Schreibweisen; mehrfach angeben zum Kombinieren (Ersetzungen in Reihenfolge der Angabe) |
|
| `-g`, `--glossar` | Glossar mit festen Ersetzungen und Schreibweisen; mehrfach angeben zum Kombinieren (Ersetzungen in Reihenfolge der Angabe). Das Video-Glossar des Ausgabeordners kommt automatisch dazu, siehe [Glossar](#glossar) |
|
||||||
| `-m`, `--model` | Ollama-Modell; Standard: `qwen3:32b` (Konstante `MODEL`) |
|
| `-m`, `--model` | Ollama-Modell; Standard: `qwen3:32b` (Konstante `MODEL`) |
|
||||||
| `--kein-bericht` | keinen Änderungsbericht schreiben |
|
| `--kein-bericht` | keinen Änderungsbericht schreiben |
|
||||||
| `--ueberschreiben` | Ausgabedatei auch ersetzen, wenn sie seit dem letzten Lauf von Hand geändert wurde |
|
| `--ueberschreiben` | Ausgabedatei auch ersetzen, wenn sie seit dem letzten Lauf von Hand geändert wurde |
|
||||||
|
|
@ -263,6 +274,31 @@ Modell an, an den Rändern der Ausschnitte nichts zu ergänzen. Eine Rohabschrif
|
||||||
(`.txt`) mit solchen Überschriften, etwa von `vtt2txt.py -z`, wird ebenso in
|
(`.txt`) mit solchen Überschriften, etwa von `vtt2txt.py -z`, wird ebenso in
|
||||||
Blöcken korrigiert.
|
Blöcken korrigiert.
|
||||||
|
|
||||||
|
## Dateiablage
|
||||||
|
|
||||||
|
```text
|
||||||
|
glossare/ gemeinsame Glossare je Sprecher oder Kanal
|
||||||
|
abschriften/<Name>/ Arbeitsordner, einer je Video (auch für Ausschnitte)
|
||||||
|
<Name>.txt Rohtext
|
||||||
|
<Name>.quelle.json Titel, Kanal, Datum, URL
|
||||||
|
<Name>.glossar.txt Glossar nur dieses Videos (optional, automatisch genutzt)
|
||||||
|
<Name>[_bereiche].korrigiert.txt Ergebnis, darf von Hand nachbearbeitet werden
|
||||||
|
<Name>[_bereiche].aenderungen.html Änderungsbericht
|
||||||
|
<Name>[_bereiche].korrigiert.parts/ Zwischenstände
|
||||||
|
fertig/<Name>[_bereiche].txt abgeschlossene Abschriften mit Quellenkopf
|
||||||
|
testdaten/<rede>/ echte Reden als Regressionstest-Eingaben (.vtt, .txt, Glossar)
|
||||||
|
tests/daten/ erfundene Beispielrede für die Tests (im Repository)
|
||||||
|
```
|
||||||
|
|
||||||
|
`glossare/`, `abschriften/`, `fertig/` und `testdaten/` stehen in `.gitignore`: Sie
|
||||||
|
enthalten Inhalte realer Beiträge und Personen und bleiben nur lokal. Wertvoll sind
|
||||||
|
davon auf Dauer nur `glossare/` und `fertig/`. Diese beiden gehören in eine eigene
|
||||||
|
Sicherung, z. B. ein zweites, **privates** Git-Repository.
|
||||||
|
|
||||||
|
Ein Video durchläuft: Rohtext holen → (Video-Glossar anlegen) → korrigieren →
|
||||||
|
Bericht prüfen und nacharbeiten → mit `abschliessen.py` abschließen. Danach bleibt
|
||||||
|
nur `fertig/<Name>.txt`.
|
||||||
|
|
||||||
## Ablauf
|
## Ablauf
|
||||||
|
|
||||||
0. **Vorprüfung:** Läuft Ollama, ist das Modell installiert? Sonst Abbruch mit
|
0. **Vorprüfung:** Läuft Ollama, ist das Modell installiert? Sonst Abbruch mit
|
||||||
|
|
@ -368,17 +404,22 @@ Ersetzungen ohne Treffer werden gesammelt gemeldet. Bei einem Glossar für einen
|
||||||
einzelnen Beitrag deutet das auf einen Tippfehler hin, bei einem gemeinsamen Glossar ist
|
einzelnen Beitrag deutet das auf einen Tippfehler hin, bei einem gemeinsamen Glossar ist
|
||||||
es normal.
|
es normal.
|
||||||
|
|
||||||
Mehrere Glossare lassen sich kombinieren: ein gemeinsames für wiederkehrende Namen
|
Es gibt zwei Arten von Glossaren:
|
||||||
und Hörfehler eines Redners oder Kanals und eines je Beitrag, z. B.
|
|
||||||
`glossare/krone-schmalz.glossar.txt` (gemeinsam) plus `glossare/ramstein.glossar.txt`.
|
|
||||||
Das gemeinsame Glossar zuerst angeben, damit seine Ersetzungen vor den speziellen
|
|
||||||
greifen.
|
|
||||||
|
|
||||||
Glossare liegen im Verzeichnis `glossare/`. Sie gehören zu realen Beiträgen und
|
- **Gemeinsame Glossare** für wiederkehrende Namen und Hörfehler eines Redners oder
|
||||||
Personen und werden deshalb nicht versioniert: `glossare/` und `*.glossar.txt` stehen
|
Kanals liegen in `glossare/`, z. B. `glossare/krone-schmalz.glossar.txt`. Sie werden
|
||||||
in `.gitignore`, die Glossare bleiben nur lokal und sollten gesondert gesichert
|
mit `-g` angegeben.
|
||||||
werden. Im Repository ist als Beispiel nur `tests/daten/beispiel.glossar.txt` zur
|
- **Video-Glossare** gelten nur für ein Video und liegen in dessen Arbeitsordner,
|
||||||
erfundenen Beispielrede.
|
benannt wie der Ordner: `abschriften/<Name>/<Name>.glossar.txt`.
|
||||||
|
`abschrift_korrigieren.py`, `glossar_vorschlag.py` und `aenderungen.py` nutzen es
|
||||||
|
automatisch (Meldung „Video-Glossar, automatisch“), und zwar nach den mit `-g`
|
||||||
|
angegebenen, damit die gemeinsamen Ersetzungen zuerst greifen. Beim Abschluss nennt
|
||||||
|
`abschliessen.py` seine Einträge, die noch in keinem gemeinsamen Glossar stehen.
|
||||||
|
|
||||||
|
Glossare gehören zu realen Beiträgen und Personen und werden deshalb nicht
|
||||||
|
versioniert (`glossare/`, `abschriften/` und `*.glossar.txt` stehen in `.gitignore`);
|
||||||
|
`glossare/` sollte gesondert gesichert werden. Im Repository ist als Beispiel nur
|
||||||
|
`tests/daten/beispiel.glossar.txt` zur erfundenen Beispielrede.
|
||||||
|
|
||||||
### Glossar-Entwurf: `glossar_vorschlag.py`
|
### Glossar-Entwurf: `glossar_vorschlag.py`
|
||||||
|
|
||||||
|
|
@ -420,6 +461,46 @@ Der Entwurf ist ein Ausgangspunkt, kein fertiges Glossar: jeden Eintrag prüfen,
|
||||||
Brauchbares ins Glossar des Beitrags (oder ins gemeinsame) übernehmen. Die Modellantworten
|
Brauchbares ins Glossar des Beitrags (oder ins gemeinsame) übernehmen. Die Modellantworten
|
||||||
werden in `<Entwurf>.parts/` zwischengespeichert.
|
werden in `<Entwurf>.parts/` zwischengespeichert.
|
||||||
|
|
||||||
|
## Abschluss: `abschliessen.py`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./abschliessen.py abschriften/<Name> # mit Rückfrage vor dem Löschen
|
||||||
|
./abschliessen.py abschriften/<Name> --ja # ohne Rückfrage
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Ergebnisse sind alle `*.korrigiert.txt` im Ordner (bei Ausschnitten mehrere).
|
||||||
|
2. Abbruch, solange ein `*.neu.korrigiert.txt` (Konflikt mit Handarbeit) da ist oder
|
||||||
|
noch `[prüfen: …]`-Marken im Text stehen (`--trotzdem` schließt dennoch ab).
|
||||||
|
3. Jedes Ergebnis kommt als `fertig/<Name>[_bereiche].txt` mit Quellenkopf:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Titel: Prof. Gabriele Krone-Schmalz – Respekt geht anders
|
||||||
|
Kanal: GGBLahnstein
|
||||||
|
Datum: 2026-07-30
|
||||||
|
Quelle: https://www.youtube.com/watch?v=rm6lADgf6GQ
|
||||||
|
Abgeschlossen: 2026-10-11
|
||||||
|
|
||||||
|
<korrigierter Text>
|
||||||
|
```
|
||||||
|
|
||||||
|
Ohne `*.quelle.json` nennt der Kopf nur die Datei. Eine vorhandene Datei in
|
||||||
|
`fertig/` mit anderem Text wird nie überschrieben; mit demselben Text gilt das
|
||||||
|
Video als schon übernommen (z. B. nach „nicht löschen“).
|
||||||
|
4. Einträge des Video-Glossars, die in keinem Glossar unter `glossare/` stehen, werden
|
||||||
|
aufgelistet, damit Brauchbares vorher ins gemeinsame Glossar wandert.
|
||||||
|
5. Der Arbeitsordner wird angezeigt und nach Rückfrage gelöscht. Nie gelöscht werden
|
||||||
|
Ordner mit fremden Dateien (andere als `.txt`, `.vtt`, `.html`, `.json`,
|
||||||
|
`*.parts/`), das Aufrufverzeichnis und alles in `testdaten/`, `glossare/`, `tests/`
|
||||||
|
oder `fertig/`.
|
||||||
|
|
||||||
|
| Option | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| `ordner` | Arbeitsordner, z. B. `abschriften/<Name>` |
|
||||||
|
| `--ziel` | Verzeichnis für fertige Abschriften; Standard: `fertig` |
|
||||||
|
| `--glossare` | Verzeichnis der gemeinsamen Glossare; Standard: `glossare` |
|
||||||
|
| `--ja` | ohne Rückfrage löschen |
|
||||||
|
| `--trotzdem` | auch mit offenen Prüfmarken abschließen |
|
||||||
|
|
||||||
## Testdaten
|
## Testdaten
|
||||||
|
|
||||||
**Im Repository:** `tests/daten/` enthält eine frei erfundene Beispielrede
|
**Im Repository:** `tests/daten/` enthält eine frei erfundene Beispielrede
|
||||||
|
|
@ -434,20 +515,29 @@ passendes Glossar (`beispiel.glossar.txt`) für die eingebauten Hörfehler wie
|
||||||
```
|
```
|
||||||
|
|
||||||
**Nur lokal:** Echte Reden gehören wegen des Urheberrechts nicht ins Repository;
|
**Nur lokal:** Echte Reden gehören wegen des Urheberrechts nicht ins Repository;
|
||||||
`testdaten/` ist deshalb in `.gitignore`, ebenso die Glossare (`glossare/`).
|
`testdaten/` ist deshalb in `.gitignore`, ebenso die Glossare. Es enthält je Rede
|
||||||
Liegen die drei Reden, mit denen das Programm entwickelt wurde, in `testdaten/` und
|
einen Ordner mit unveränderlichen Eingaben, nie Ausgaben:
|
||||||
ihre Glossare in `glossare/`, laufen zusätzlich die Tests, die diese Glossare
|
|
||||||
prüfen; sonst werden sie übersprungen:
|
|
||||||
|
|
||||||
- `ramstein.de.*`: Rede über die US Air Base Ramstein (Glossar `glossare/ramstein.glossar.txt`)
|
- `testdaten/ramstein/`: `ramstein.de.vtt`, `ramstein.de.txt`, `ramstein.glossar.txt`
|
||||||
- `magdeburg.de-orig.*`: Rede in Magdeburg (Glossar `glossare/magdeburg.glossar.txt`)
|
(Rede über die US Air Base Ramstein)
|
||||||
- `Lahnstein.de-orig.*`: Rede „Respekt geht anders“ in Lahnstein (Glossar
|
- `testdaten/magdeburg/`: `magdeburg.de-orig.vtt`, `.txt`, `magdeburg.glossar.txt`
|
||||||
`glossare/lahnstein.glossar.txt`, erstellt mit Hilfe von `glossar_vorschlag.py`),
|
(Rede in Magdeburg)
|
||||||
zu laden mit
|
- `testdaten/lahnstein/`: `Lahnstein.de-orig.vtt`, `.txt`, `lahnstein.glossar.txt`
|
||||||
`./yt_untertitel.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -n Lahnstein -d testdaten`
|
(Rede „Respekt geht anders“, Glossar mit Hilfe von `glossar_vorschlag.py`), zu laden
|
||||||
|
mit `./yt_untertitel.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -n Lahnstein -d testdaten/lahnstein`
|
||||||
|
|
||||||
Alle drei mit dem gemeinsamen `glossare/krone-schmalz.glossar.txt` davor. Ein Lauf über eine
|
Liegen sie dort und `glossare/krone-schmalz.glossar.txt` daneben, laufen zusätzlich
|
||||||
Rede dauert mit `qwen3:32b` vollständig auf einer RTX 3090 etwa 6–8 Minuten.
|
die Tests, die diese Glossare prüfen; sonst werden sie übersprungen. Eine echte Rede
|
||||||
|
korrigiert man mit Ausgabe in einen Arbeitsordner, damit `testdaten/` sauber bleibt:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./abschrift_korrigieren.py -i testdaten/ramstein/ramstein.de.txt \
|
||||||
|
-g glossare/krone-schmalz.glossar.txt -g testdaten/ramstein/ramstein.glossar.txt \
|
||||||
|
-o abschriften/ramstein.de/ramstein.de.korrigiert.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Ein Lauf über eine Rede dauert mit `qwen3:32b` vollständig auf einer RTX 3090 etwa
|
||||||
|
6–8 Minuten.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
|
@ -457,9 +547,9 @@ python3 -m pytest
|
||||||
|
|
||||||
Die Tests brauchen weder Ollama noch Netz: Modellaufrufe und `yt-dlp` werden durch
|
Die Tests brauchen weder Ollama noch Netz: Modellaufrufe und `yt-dlp` werden durch
|
||||||
Fakes ersetzt. Als Text dient die erfundene Beispielrede in `tests/daten/` (siehe
|
Fakes ersetzt. Als Text dient die erfundene Beispielrede in `tests/daten/` (siehe
|
||||||
[Testdaten](#testdaten)). In einem frischen Klon laufen 318 Tests, 7 werden
|
[Testdaten](#testdaten)). In einem frischen Klon laufen 339 Tests, 7 werden
|
||||||
übersprungen; sie prüfen die Glossare der echten Reden und laufen nur, wenn diese
|
übersprungen; sie prüfen die Glossare der echten Reden und laufen nur, wenn diese
|
||||||
lokal in `testdaten/` liegen (dann 325).
|
lokal in `testdaten/` liegen (dann 346).
|
||||||
|
|
||||||
`tests/test_abschrift_korrigieren.py` prüft Abschnittsbildung, Satzgrenzen
|
`tests/test_abschrift_korrigieren.py` prüft Abschnittsbildung, Satzgrenzen
|
||||||
(Abkürzungen und Ordnungszahlen sind kein Satzende), Glossar (jede Ersetzung des
|
(Abkürzungen und Ordnungszahlen sind kein Satzende), Glossar (jede Ersetzung des
|
||||||
|
|
@ -467,7 +557,8 @@ Beispielglossars trifft die Beispielrede; lokal zusätzlich: jede Ersetzung im
|
||||||
Glossar einer Rede trifft diese Rede, jede im gemeinsamen Glossar mindestens eine),
|
Glossar einer Rede trifft diese Rede, jede im gemeinsamen Glossar mindestens eine),
|
||||||
Prompts (keine Namensteile ergänzen), Streaming-Auswertung, Absatzgliederung (Text
|
Prompts (keine Namensteile ergänzen), Streaming-Auswertung, Absatzgliederung (Text
|
||||||
bleibt unverändert, 3–10 Sätze), Verbinden kurzer Absätze (Modell wird nur gefragt,
|
bleibt unverändert, 3–10 Sätze), Verbinden kurzer Absätze (Modell wird nur gefragt,
|
||||||
wenn beide Nachbarn passen), Zwischenspeicher/Fortsetzen, Schutz von Handarbeit
|
wenn beide Nachbarn passen), Zwischenspeicher/Fortsetzen, Arbeitsordner,
|
||||||
|
Quellenangabe und automatisches Video-Glossar, Schutz von Handarbeit
|
||||||
(`.neu.korrigiert.txt`, `--ueberschreiben`), Zeitauswahl, Sprachen und die
|
(`.neu.korrigiert.txt`, `--ueberschreiben`), Zeitauswahl, Sprachen und die
|
||||||
Kommandozeile.
|
Kommandozeile.
|
||||||
`tests/test_glossar_vorschlag.py` prüft Filter (nur Funde im Text, keine bekannten),
|
`tests/test_glossar_vorschlag.py` prüft Filter (nur Funde im Text, keine bekannten),
|
||||||
|
|
@ -481,6 +572,9 @@ Die Tests nutzen eine kleine eigene Wortliste statt der des Systems.
|
||||||
(Original-Audio, manuell vor automatisch) und Fehlerfälle.
|
(Original-Audio, manuell vor automatisch) und Fehlerfälle.
|
||||||
`tests/test_yt_untertitel.py` prüft Spurauswahl, Aufruf von `yt-dlp` und
|
`tests/test_yt_untertitel.py` prüft Spurauswahl, Aufruf von `yt-dlp` und
|
||||||
Fehlerfälle (ohne Netz, `yt-dlp` wird durch Fakes ersetzt).
|
Fehlerfälle (ohne Netz, `yt-dlp` wird durch Fakes ersetzt).
|
||||||
|
`tests/test_abschliessen.py` prüft den Abschluss: Quellenkopf, mehrere Ausschnitte,
|
||||||
|
Abbruch bei Prüfmarken und Konflikten, nie überschreiben, Rückfrage, Schutz vor dem
|
||||||
|
Löschen fremder und geschützter Ordner und den Hinweis auf Video-Glossar-Einträge.
|
||||||
`tests/test_vtt2txt.py` prüft die VTT-Umwandlung: die Beispiel-`.vtt` (und lokal
|
`tests/test_vtt2txt.py` prüft die VTT-Umwandlung: die Beispiel-`.vtt` (und lokal
|
||||||
jede Rede in `testdaten/`) ergibt genau die zugehörige `.txt`, echte Wiederholungen
|
jede Rede in `testdaten/`) ergibt genau die zugehörige `.txt`, echte Wiederholungen
|
||||||
bleiben erhalten, auch bei Untertiteln ohne rollendes Fenster, dazu Zeitauswahl
|
bleiben erhalten, auch bei Untertiteln ohne rollendes Fenster, dazu Zeitauswahl
|
||||||
|
|
@ -494,7 +588,9 @@ Verbinden kurzer Absätze wird in `<Ausgabe>.parts/` gespeichert. Ein erneuter
|
||||||
Lauf überspringt alles, was schon berechnet ist.
|
Lauf überspringt alles, was schon berechnet ist.
|
||||||
Ändern sich Modell (auch per `-m`), Prompt, Glossar oder Text eines Abschnitts, wird
|
Ändern sich Modell (auch per `-m`), Prompt, Glossar oder Text eines Abschnitts, wird
|
||||||
genau dieser Teil neu berechnet. Glossar-Begriffe stehen im Prompt; ein neuer Begriff
|
genau dieser Teil neu berechnet. Glossar-Begriffe stehen im Prompt; ein neuer Begriff
|
||||||
lässt deshalb alle Abschnitte neu korrigieren. Den Ordner kann man jederzeit löschen.
|
lässt deshalb alle Abschnitte neu korrigieren. Den Ordner kann man jederzeit löschen;
|
||||||
|
`abschliessen.py` löscht ihn mit dem Arbeitsordner. Er wächst sonst mit jeder
|
||||||
|
Prompt- oder Modelländerung, weil alte Fassungen liegen bleiben.
|
||||||
`glossar_vorschlag.py` speichert seine Modellantworten ebenso in
|
`glossar_vorschlag.py` speichert seine Modellantworten ebenso in
|
||||||
`<Entwurf>.parts/`.
|
`<Entwurf>.parts/`.
|
||||||
|
|
||||||
|
|
@ -530,6 +626,15 @@ In `vtt2txt.py`:
|
||||||
| `MAX_SNAP_SECONDS` | 30 | so weit darf ein Zeitbereich bis zur nächsten Satzgrenze wachsen |
|
| `MAX_SNAP_SECONDS` | 30 | so weit darf ein Zeitbereich bis zur nächsten Satzgrenze wachsen |
|
||||||
| `ROLLING_SHARE` | 0.5 | ab diesem Anteil sich überlappender Blöcke gilt eine Datei als rollend |
|
| `ROLLING_SHARE` | 0.5 | ab diesem Anteil sich überlappender Blöcke gilt eine Datei als rollend |
|
||||||
|
|
||||||
|
In `yt_untertitel.py` und `abschliessen.py` (Pfade relativ zum Aufrufverzeichnis, also
|
||||||
|
im Programmordner aufrufen):
|
||||||
|
|
||||||
|
| Konstante | Standard | Bedeutung |
|
||||||
|
|---|---|---|
|
||||||
|
| `WORK_DIR` | `abschriften` | Arbeitsordner je Video: `abschriften/<Name>/` |
|
||||||
|
| `FINISHED_DIR` | `fertig` | abgeschlossene Abschriften (Option `--ziel`) |
|
||||||
|
| `GLOSSARY_DIR` | `glossare` | gemeinsame Glossare (Option `--glossare`) |
|
||||||
|
|
||||||
## Bekannte Grenzen
|
## Bekannte Grenzen
|
||||||
|
|
||||||
- Das Modell ändert gelegentlich Wörter eigenmächtig, auch sinnverändernd
|
- Das Modell ändert gelegentlich Wörter eigenmächtig, auch sinnverändernd
|
||||||
|
|
|
||||||
244
abschliessen.py
Executable file
244
abschliessen.py
Executable file
|
|
@ -0,0 +1,244 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Schließt die Abschrift eines Videos ab: Die korrigierte Textdatei kommt mit einem
|
||||||
|
Quellenkopf nach fertig/, der Arbeitsordner abschriften/<Name>/ wird nach Rückfrage
|
||||||
|
gelöscht."""
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from abschrift_korrigieren import read_glossary, video_glossary
|
||||||
|
from aenderungen import REVIEW_MARK
|
||||||
|
|
||||||
|
FINISHED_DIR = Path("fertig")
|
||||||
|
GLOSSARY_DIR = Path("glossare")
|
||||||
|
# What a working folder may hold; anything else stops the deletion
|
||||||
|
WORK_SUFFIXES = {".txt", ".vtt", ".html", ".json"}
|
||||||
|
# Never deleted, nor anything inside them
|
||||||
|
PROTECTED = {"testdaten", "glossare", "tests", "fertig"}
|
||||||
|
SHOWN_MARKS = 3
|
||||||
|
|
||||||
|
|
||||||
|
def results(folder):
|
||||||
|
"""Corrected texts of the folder (several for excerpts), without conflicts."""
|
||||||
|
return sorted(
|
||||||
|
p for p in folder.glob("*.korrigiert.txt")
|
||||||
|
if not p.name.endswith(".neu.korrigiert.txt")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def finished_name(result):
|
||||||
|
"""rede_00-05-00_bis_00-06-00.korrigiert.txt -> rede_00-05-00_bis_00-06-00.txt"""
|
||||||
|
return result.name.removesuffix(".korrigiert.txt") + ".txt"
|
||||||
|
|
||||||
|
|
||||||
|
def read_source(folder):
|
||||||
|
"""Contents of the folder's <Rohtext>.quelle.json, None without one."""
|
||||||
|
for path in sorted(folder.glob("*.quelle.json")):
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def format_date(text):
|
||||||
|
""""20260328" -> "2026-03-28"; anything else unchanged."""
|
||||||
|
if text and len(text) == 8 and text.isdigit():
|
||||||
|
return f"{text[:4]}-{text[4:6]}-{text[6:]}"
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def header(source, result, today):
|
||||||
|
"""Source lines in front of the finished text, then a blank line."""
|
||||||
|
lines = []
|
||||||
|
if source:
|
||||||
|
for label, value in (
|
||||||
|
("Titel", source.get("titel")),
|
||||||
|
("Kanal", source.get("kanal")),
|
||||||
|
("Datum", format_date(source.get("datum"))),
|
||||||
|
("Quelle", source.get("url")),
|
||||||
|
):
|
||||||
|
if value:
|
||||||
|
lines.append(f"{label}: {value}")
|
||||||
|
else:
|
||||||
|
lines.append(f"Datei: {result.name}")
|
||||||
|
lines.append(f"Abgeschlossen: {today}")
|
||||||
|
return "\n".join(lines) + "\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
def already_finished(target, text):
|
||||||
|
"""True if the target holds this text below a header."""
|
||||||
|
if not target.exists():
|
||||||
|
return False
|
||||||
|
_, _, body = target.read_text(encoding="utf-8").partition("\n\n")
|
||||||
|
return body == text
|
||||||
|
|
||||||
|
|
||||||
|
def open_marks(text):
|
||||||
|
"""(line number, line) of every line with a review mark."""
|
||||||
|
return [
|
||||||
|
(number, line.strip())
|
||||||
|
for number, line in enumerate(text.splitlines(), start=1)
|
||||||
|
if REVIEW_MARK.search(line)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def unshared_entries(glossary, glossary_dir):
|
||||||
|
"""Entries of the video glossary that are in no shared glossary."""
|
||||||
|
shared_terms, shared_replacements = set(), set()
|
||||||
|
for path in sorted(glossary_dir.glob("*.glossar.txt")):
|
||||||
|
terms, replacements = read_glossary(path)
|
||||||
|
shared_terms.update(terms)
|
||||||
|
shared_replacements.update(replacements)
|
||||||
|
terms, replacements = read_glossary(glossary)
|
||||||
|
return [
|
||||||
|
f"{wrong} => {right}" for wrong, right in replacements
|
||||||
|
if (wrong, right) not in shared_replacements
|
||||||
|
] + [term for term in terms if term not in shared_terms]
|
||||||
|
|
||||||
|
|
||||||
|
def deletion_blockers(folder):
|
||||||
|
"""Reasons not to delete the folder; empty for a plain working folder."""
|
||||||
|
resolved = folder.resolve()
|
||||||
|
cwd = Path.cwd().resolve()
|
||||||
|
if resolved == cwd or resolved in cwd.parents:
|
||||||
|
return ["Er ist das Aufrufverzeichnis oder liegt darüber."]
|
||||||
|
for path in (resolved, *resolved.parents):
|
||||||
|
if path == cwd:
|
||||||
|
break
|
||||||
|
if path.name in PROTECTED:
|
||||||
|
return [f"Er ist oder liegt in „{path.name}“, das nie gelöscht wird."]
|
||||||
|
return [
|
||||||
|
f"Fremde Datei: {entry.name}"
|
||||||
|
for entry in sorted(folder.iterdir())
|
||||||
|
if not (
|
||||||
|
(entry.is_dir() and entry.name.endswith(".parts"))
|
||||||
|
or (entry.is_file() and entry.suffix.lower() in WORK_SUFFIXES)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def size(path):
|
||||||
|
"""Size of a file or folder, readable: "830 B", "17 KB"."""
|
||||||
|
if path.is_file():
|
||||||
|
total = path.stat().st_size
|
||||||
|
else:
|
||||||
|
total = sum(p.stat().st_size for p in path.rglob("*") if p.is_file())
|
||||||
|
return f"{total} B" if total < 1024 else f"{total / 1024:.0f} KB"
|
||||||
|
|
||||||
|
|
||||||
|
def confirm(question):
|
||||||
|
try:
|
||||||
|
return input(question).strip().lower() in {"j", "ja", "y", "yes"}
|
||||||
|
except EOFError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Schließt die Abschrift eines Videos ab: Ergebnis mit Quellenkopf "
|
||||||
|
"nach fertig/, Arbeitsordner nach Rückfrage löschen."
|
||||||
|
)
|
||||||
|
parser.add_argument("ordner", type=Path, help="Arbeitsordner, z. B. abschriften/<Name>")
|
||||||
|
parser.add_argument(
|
||||||
|
"--ziel", type=Path, default=FINISHED_DIR,
|
||||||
|
help=f"Verzeichnis für fertige Abschriften (Standard: {FINISHED_DIR})",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--glossare", type=Path, default=GLOSSARY_DIR,
|
||||||
|
help=f"Verzeichnis der gemeinsamen Glossare (Standard: {GLOSSARY_DIR})",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--ja", action="store_true", help="Arbeitsordner ohne Rückfrage löschen"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--trotzdem", action="store_true",
|
||||||
|
help="auch abschließen, wenn noch Prüfmarken [prüfen: …] im Text stehen",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
if not args.ordner.is_dir():
|
||||||
|
parser.error(f"Ordner nicht gefunden: {args.ordner}")
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parse_args()
|
||||||
|
folder = args.ordner
|
||||||
|
|
||||||
|
found = results(folder)
|
||||||
|
if not found:
|
||||||
|
raise SystemExit(f"Keine korrigierte Abschrift (*.korrigiert.txt) in {folder}.")
|
||||||
|
conflicts = sorted(folder.glob("*.neu.korrigiert.txt"))
|
||||||
|
if conflicts:
|
||||||
|
names = ", ".join(p.name for p in conflicts)
|
||||||
|
raise SystemExit(
|
||||||
|
f"Ungeklärter Konflikt: {names}. Erst die Änderungen übertragen bzw. die "
|
||||||
|
"Datei löschen, dann abschließen."
|
||||||
|
)
|
||||||
|
|
||||||
|
texts = {result: result.read_text(encoding="utf-8") for result in found}
|
||||||
|
marked = {result: open_marks(text) for result, text in texts.items()}
|
||||||
|
if any(marked.values()):
|
||||||
|
for result, marks in marked.items():
|
||||||
|
if marks:
|
||||||
|
print(f"{result.name}: {len(marks)} Zeile(n) mit Prüfmarken, z. B.")
|
||||||
|
for number, line in marks[:SHOWN_MARKS]:
|
||||||
|
print(f" Zeile {number}: {line[:120]}")
|
||||||
|
if not args.trotzdem:
|
||||||
|
raise SystemExit(
|
||||||
|
"Noch offene Prüfmarken. Erst klären, oder mit --trotzdem abschließen."
|
||||||
|
)
|
||||||
|
|
||||||
|
targets = {result: args.ziel / finished_name(result) for result in found}
|
||||||
|
# A finished text with the same body is from an earlier run (e.g. answered
|
||||||
|
# "n" when asked to delete); a different one is never overwritten
|
||||||
|
done = {result for result, target in targets.items() if already_finished(target, texts[result])}
|
||||||
|
existing = [str(t) for r, t in targets.items() if t.exists() and r not in done]
|
||||||
|
if existing:
|
||||||
|
raise SystemExit(
|
||||||
|
f"Gibt es schon in {args.ziel} mit anderem Text: {', '.join(existing)}. "
|
||||||
|
"Nichts überschrieben; vorhandene Datei umbenennen oder entfernen."
|
||||||
|
)
|
||||||
|
|
||||||
|
source = read_source(folder)
|
||||||
|
if source is None and len(done) < len(targets):
|
||||||
|
print("Hinweis: keine Quellenangabe (*.quelle.json); der Kopf nennt nur die Datei.")
|
||||||
|
today = date.today().isoformat()
|
||||||
|
args.ziel.mkdir(parents=True, exist_ok=True)
|
||||||
|
for result, target in targets.items():
|
||||||
|
if result in done:
|
||||||
|
print(f"Bereits fertig: {target}")
|
||||||
|
continue
|
||||||
|
target.write_text(header(source, result, today) + texts[result], encoding="utf-8")
|
||||||
|
print(f"Fertig: {target}")
|
||||||
|
|
||||||
|
glossary = video_glossary(folder)
|
||||||
|
if glossary is not None:
|
||||||
|
missing = unshared_entries(glossary, args.glossare)
|
||||||
|
if missing:
|
||||||
|
print(
|
||||||
|
f"\nDiese Einträge aus {glossary} stehen in keinem Glossar in "
|
||||||
|
f"{args.glossare}/; vor dem Löschen ggf. in ein gemeinsames übernehmen:"
|
||||||
|
)
|
||||||
|
for entry in missing:
|
||||||
|
print(f" {entry}")
|
||||||
|
|
||||||
|
blockers = deletion_blockers(folder)
|
||||||
|
if blockers:
|
||||||
|
print(f"\nArbeitsordner {folder} bleibt erhalten:")
|
||||||
|
for reason in blockers:
|
||||||
|
print(f" {reason}")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"\nArbeitsordner {folder}:")
|
||||||
|
for entry in sorted(folder.iterdir()):
|
||||||
|
print(f" {entry.name}{'/' if entry.is_dir() else ''} ({size(entry)})")
|
||||||
|
if not args.ja and not confirm(f"Arbeitsordner {folder} löschen? [j/N] "):
|
||||||
|
print("Nicht gelöscht.")
|
||||||
|
return
|
||||||
|
shutil.rmtree(folder)
|
||||||
|
print(f"Gelöscht: {folder}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
|
|
@ -21,7 +21,9 @@ from vtt2txt import (
|
||||||
add_time_options, join_segments, range_argv, resolve_time_ranges, split_segments,
|
add_time_options, join_segments, range_argv, resolve_time_ranges, split_segments,
|
||||||
time_suffix, vtt_to_text,
|
time_suffix, vtt_to_text,
|
||||||
)
|
)
|
||||||
from yt_untertitel import YT_DLP, base_language, default_name, original_language
|
from yt_untertitel import (
|
||||||
|
YT_DLP, base_language, default_name, original_language, work_folder, write_source,
|
||||||
|
)
|
||||||
|
|
||||||
MODEL = "qwen3:32b"
|
MODEL = "qwen3:32b"
|
||||||
OLLAMA_URL = "http://127.0.0.1:11434/api/chat"
|
OLLAMA_URL = "http://127.0.0.1:11434/api/chat"
|
||||||
|
|
@ -264,6 +266,22 @@ def read_glossary(path):
|
||||||
return terms, replacements
|
return terms, replacements
|
||||||
|
|
||||||
|
|
||||||
|
def video_glossary(folder):
|
||||||
|
"""Glossary of the video in `folder`, named like the folder:
|
||||||
|
abschriften/<Name>/<Name>.glossar.txt; None if there is none."""
|
||||||
|
path = folder / f"{folder.resolve().name}.glossar.txt"
|
||||||
|
return path if path.is_file() else None
|
||||||
|
|
||||||
|
|
||||||
|
def with_video_glossary(glossaries, folder):
|
||||||
|
"""The given glossaries plus the video glossary of `folder` (last, after the
|
||||||
|
shared ones), unless it was given; returns (glossaries, added or None)."""
|
||||||
|
found = video_glossary(folder)
|
||||||
|
if found is None or any(g.resolve() == found.resolve() for g in glossaries):
|
||||||
|
return list(glossaries), None
|
||||||
|
return [*glossaries, found], found
|
||||||
|
|
||||||
|
|
||||||
def apply_replacements(text, replacements):
|
def apply_replacements(text, replacements):
|
||||||
"""Replace whole words in file order; report rules without any match."""
|
"""Replace whole words in file order; report rules without any match."""
|
||||||
unmatched = []
|
unmatched = []
|
||||||
|
|
@ -633,8 +651,8 @@ def parse_args():
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-o", "--output", type=Path,
|
"-o", "--output", type=Path,
|
||||||
help="Ausgabedatei (Standard: <Eingabe ohne Endung>.korrigiert.txt, "
|
help="Ausgabedatei (Standard: <Eingabe ohne Endung>.korrigiert.txt, "
|
||||||
"mit -u <Kanal>_<Titel>_<Datum>.korrigiert.txt; mit -z zusätzlich "
|
"mit -u abschriften/<Name>/<Name>.korrigiert.txt mit "
|
||||||
"die Bereiche)",
|
"<Name> = <Kanal>_<Titel>_<Datum>; mit -z zusätzlich die Bereiche)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-g", "--glossar", type=Path, action="append", default=[],
|
"-g", "--glossar", type=Path, action="append", default=[],
|
||||||
|
|
@ -709,9 +727,12 @@ def read_source(args):
|
||||||
f"Der Beitrag ist auf „{lang}“; unterstützt: {', '.join(PROMPTS)} (-l)."
|
f"Der Beitrag ist auf „{lang}“; unterstützt: {', '.join(PROMPTS)} (-l)."
|
||||||
)
|
)
|
||||||
suffix = time_suffix(args.bereiche)
|
suffix = time_suffix(args.bereiche)
|
||||||
target = args.output or Path(f"{default_name(info)}{suffix}.korrigiert.txt")
|
name = default_name(info)
|
||||||
|
target = args.output or work_folder(name) / f"{name}{suffix}.korrigiert.txt"
|
||||||
raw = text + "\n"
|
raw = text + "\n"
|
||||||
save_raw_text(raw_text_path(target), raw)
|
raw_path = raw_text_path(target)
|
||||||
|
save_raw_text(raw_path, raw)
|
||||||
|
write_source(info, raw_path.with_suffix(".quelle.json"))
|
||||||
return raw, target, lang
|
return raw, target, lang
|
||||||
|
|
||||||
raw = args.input.read_text(encoding="utf-8-sig")
|
raw = args.input.read_text(encoding="utf-8-sig")
|
||||||
|
|
@ -762,10 +783,12 @@ def main():
|
||||||
parts_dir = target.with_suffix(".parts")
|
parts_dir = target.with_suffix(".parts")
|
||||||
|
|
||||||
terms, replacements = [], []
|
terms, replacements = [], []
|
||||||
for glossary in args.glossar:
|
glossaries, automatic = with_video_glossary(args.glossar, target.parent)
|
||||||
|
for glossary in glossaries:
|
||||||
more_terms, more_replacements = read_glossary(glossary)
|
more_terms, more_replacements = read_glossary(glossary)
|
||||||
|
note = " (Video-Glossar, automatisch)" if glossary == automatic else ""
|
||||||
print(
|
print(
|
||||||
f"Glossar {glossary}: {len(more_terms)} Begriffe, "
|
f"Glossar {glossary}{note}: {len(more_terms)} Begriffe, "
|
||||||
f"{len(more_replacements)} feste Ersetzungen"
|
f"{len(more_replacements)} feste Ersetzungen"
|
||||||
)
|
)
|
||||||
terms += more_terms
|
terms += more_terms
|
||||||
|
|
|
||||||
|
|
@ -473,13 +473,16 @@ def parse_args():
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
# abschrift_korrigieren imports this module: import it only when needed
|
# abschrift_korrigieren imports this module: import it only when needed
|
||||||
from abschrift_korrigieren import apply_replacements, read_glossary
|
from abschrift_korrigieren import apply_replacements, read_glossary, with_video_glossary
|
||||||
from yt_untertitel import base_language
|
from yt_untertitel import base_language
|
||||||
|
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
raw = read_raw(args.roh)
|
raw = read_raw(args.roh)
|
||||||
terms, replacements = [], []
|
terms, replacements = [], []
|
||||||
for glossary in args.glossar:
|
glossaries, automatic = with_video_glossary(args.glossar, args.korrigiert.parent)
|
||||||
|
if automatic is not None:
|
||||||
|
print(f"Video-Glossar (automatisch): {automatic}")
|
||||||
|
for glossary in glossaries:
|
||||||
more_terms, more_replacements = read_glossary(glossary)
|
more_terms, more_replacements = read_glossary(glossary)
|
||||||
terms += more_terms
|
terms += more_terms
|
||||||
replacements += more_replacements
|
replacements += more_replacements
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ from vtt2txt import (
|
||||||
)
|
)
|
||||||
from yt_untertitel import (
|
from yt_untertitel import (
|
||||||
YT_DLP, choose_track, default_name, download, fetch_info, no_track_message,
|
YT_DLP, choose_track, default_name, download, fetch_info, no_track_message,
|
||||||
original_language,
|
original_language, work_folder, write_source,
|
||||||
)
|
)
|
||||||
|
|
||||||
FLOW_WIDTH = 80 # Zeilenbreite für --fliesstext
|
FLOW_WIDTH = 80 # Zeilenbreite für --fliesstext
|
||||||
|
|
@ -31,8 +31,8 @@ def parse_args():
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-o", "--output", type=Path,
|
"-o", "--output", type=Path,
|
||||||
help="Ausgabedatei (Standard: <Kanal>_<Titel>_<Datum>.txt, "
|
help="Ausgabedatei (Standard: abschriften/<Name>/<Name>.txt mit "
|
||||||
"mit -z zusätzlich die Bereiche)",
|
"<Name> = <Kanal>_<Titel>_<Datum>, mit -z zusätzlich die Bereiche)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-f", "--fliesstext", action="store_true",
|
"-f", "--fliesstext", action="store_true",
|
||||||
|
|
@ -87,7 +87,8 @@ def main():
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
info, text = fetch_text(args.url, args.lang, args.bereiche, args.exakt)
|
info, text = fetch_text(args.url, args.lang, args.bereiche, args.exakt)
|
||||||
suffix = time_suffix(args.bereiche)
|
suffix = time_suffix(args.bereiche)
|
||||||
output = args.output or Path(f"{default_name(info)}{suffix}.txt")
|
name = default_name(info)
|
||||||
|
output = args.output or work_folder(name) / f"{name}{suffix}.txt"
|
||||||
segments = split_segments(text)
|
segments = split_segments(text)
|
||||||
|
|
||||||
if args.fliesstext:
|
if args.fliesstext:
|
||||||
|
|
@ -104,6 +105,7 @@ def main():
|
||||||
|
|
||||||
output.parent.mkdir(parents=True, exist_ok=True)
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
output.write_text(text + "\n", encoding="utf-8")
|
output.write_text(text + "\n", encoding="utf-8")
|
||||||
|
write_source(info, output.with_suffix(".quelle.json"))
|
||||||
print(
|
print(
|
||||||
f"Gespeichert: {output} "
|
f"Gespeichert: {output} "
|
||||||
f"({len(text.splitlines())} Zeilen, "
|
f"({len(text.splitlines())} Zeilen, "
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import re
|
||||||
from aenderungen import compare, read_raw
|
from aenderungen import compare, read_raw
|
||||||
from abschrift_korrigieren import (
|
from abschrift_korrigieren import (
|
||||||
MODEL, apply_replacements, chat_request, check_ollama, ollama_open, read_glossary,
|
MODEL, apply_replacements, chat_request, check_ollama, ollama_open, read_glossary,
|
||||||
split_chunks,
|
split_chunks, with_video_glossary,
|
||||||
)
|
)
|
||||||
from vtt2txt import split_segments
|
from vtt2txt import split_segments
|
||||||
|
|
||||||
|
|
@ -226,7 +226,10 @@ def main():
|
||||||
|
|
||||||
raw = text
|
raw = text
|
||||||
known, replacements = set(), []
|
known, replacements = set(), []
|
||||||
for glossary in args.glossar:
|
glossaries, automatic = with_video_glossary(args.glossar, args.input.parent)
|
||||||
|
if automatic is not None:
|
||||||
|
print(f"Video-Glossar (automatisch): {automatic}")
|
||||||
|
for glossary in glossaries:
|
||||||
_, more = read_glossary(glossary)
|
_, more = read_glossary(glossary)
|
||||||
replacements += more
|
replacements += more
|
||||||
known.update(wrong for wrong, _ in more)
|
known.update(wrong for wrong, _ in more)
|
||||||
|
|
|
||||||
162
tests/test_abschliessen.py
Normal file
162
tests/test_abschliessen.py
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
"""Tests for abschliessen.py (finish a video: result to fertig/, delete working folder)."""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import abschliessen as ab
|
||||||
|
|
||||||
|
TEXT = "Der Kreml ist in Moskau.\n\nDas ist bekannt.\n"
|
||||||
|
SOURCE = {
|
||||||
|
"titel": "Eine Rede", "kanal": "Kanal", "datum": "20260328",
|
||||||
|
"url": "https://www.youtube.com/watch?v=abc123", "id": "abc123",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def work(tmp_path, monkeypatch):
|
||||||
|
"""abschriften/rede/ with everything a run leaves behind; cwd is tmp_path."""
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
folder = tmp_path / "abschriften" / "rede"
|
||||||
|
parts = folder / "rede.korrigiert.parts"
|
||||||
|
parts.mkdir(parents=True)
|
||||||
|
(parts / "part001-abc.txt").write_text("x", encoding="utf-8")
|
||||||
|
(folder / "rede.txt").write_text("der kremmel ist in moskau\n", encoding="utf-8")
|
||||||
|
(folder / "rede.korrigiert.txt").write_text(TEXT, encoding="utf-8")
|
||||||
|
(folder / "rede.aenderungen.html").write_text("<p>", encoding="utf-8")
|
||||||
|
(folder / "rede.quelle.json").write_text(json.dumps(SOURCE), encoding="utf-8")
|
||||||
|
return folder
|
||||||
|
|
||||||
|
|
||||||
|
def run_main(monkeypatch, *args, answer=None):
|
||||||
|
monkeypatch.setattr(sys, "argv", ["abschliessen.py", *map(str, args)])
|
||||||
|
if answer is not None:
|
||||||
|
monkeypatch.setattr("builtins.input", lambda question: answer)
|
||||||
|
ab.main()
|
||||||
|
|
||||||
|
|
||||||
|
def test_finish_writes_header_and_deletes(work, tmp_path, monkeypatch, capsys):
|
||||||
|
run_main(monkeypatch, work, "--ja")
|
||||||
|
|
||||||
|
finished = (tmp_path / "fertig" / "rede.txt").read_text(encoding="utf-8")
|
||||||
|
assert finished == (
|
||||||
|
"Titel: Eine Rede\nKanal: Kanal\nDatum: 2026-03-28\n"
|
||||||
|
"Quelle: https://www.youtube.com/watch?v=abc123\n"
|
||||||
|
f"Abgeschlossen: {date.today().isoformat()}\n\n" + TEXT
|
||||||
|
)
|
||||||
|
assert not work.exists()
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "rede.korrigiert.parts/" in out and f"Gelöscht: {work}" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_answer_no_keeps_folder_and_rerun_deletes(work, tmp_path, monkeypatch, capsys):
|
||||||
|
run_main(monkeypatch, work, answer="n")
|
||||||
|
assert work.is_dir()
|
||||||
|
assert (tmp_path / "fertig" / "rede.txt").is_file()
|
||||||
|
assert "Nicht gelöscht." in capsys.readouterr().out
|
||||||
|
|
||||||
|
# Later: the result is already in fertig/, only the deletion is left
|
||||||
|
run_main(monkeypatch, work, answer="j")
|
||||||
|
assert not work.exists()
|
||||||
|
assert "Bereits fertig" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_review_marks_stop(work, tmp_path, monkeypatch, capsys):
|
||||||
|
(work / "rede.korrigiert.txt").write_text(
|
||||||
|
"Der Kreml [prüfen: Kremmel] ist in Moskau.\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
with pytest.raises(SystemExit, match="offene Prüfmarken"):
|
||||||
|
run_main(monkeypatch, work, "--ja")
|
||||||
|
assert "Zeile 1: Der Kreml [prüfen: Kremmel]" in capsys.readouterr().out
|
||||||
|
assert not (tmp_path / "fertig").exists() and work.is_dir()
|
||||||
|
|
||||||
|
run_main(monkeypatch, work, "--ja", "--trotzdem")
|
||||||
|
assert (tmp_path / "fertig" / "rede.txt").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def test_conflict_stops(work, monkeypatch):
|
||||||
|
(work / "rede.neu.korrigiert.txt").write_text(TEXT, encoding="utf-8")
|
||||||
|
with pytest.raises(SystemExit, match="Ungeklärter Konflikt: rede.neu.korrigiert.txt"):
|
||||||
|
run_main(monkeypatch, work, "--ja")
|
||||||
|
assert work.is_dir()
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_result_stops(work, monkeypatch):
|
||||||
|
(work / "rede.korrigiert.txt").unlink()
|
||||||
|
with pytest.raises(SystemExit, match="Keine korrigierte Abschrift"):
|
||||||
|
run_main(monkeypatch, work, "--ja")
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_different_target_is_never_overwritten(work, tmp_path, monkeypatch):
|
||||||
|
(tmp_path / "fertig").mkdir()
|
||||||
|
(tmp_path / "fertig" / "rede.txt").write_text("Anderer Text\n", encoding="utf-8")
|
||||||
|
with pytest.raises(SystemExit, match="mit anderem Text"):
|
||||||
|
run_main(monkeypatch, work, "--ja")
|
||||||
|
assert (tmp_path / "fertig" / "rede.txt").read_text(encoding="utf-8") == "Anderer Text\n"
|
||||||
|
assert work.is_dir()
|
||||||
|
|
||||||
|
|
||||||
|
def test_without_source_header_names_the_file(work, tmp_path, monkeypatch, capsys):
|
||||||
|
(work / "rede.quelle.json").unlink()
|
||||||
|
run_main(monkeypatch, work, "--ja")
|
||||||
|
finished = (tmp_path / "fertig" / "rede.txt").read_text(encoding="utf-8")
|
||||||
|
assert finished.startswith("Datei: rede.korrigiert.txt\nAbgeschlossen: ")
|
||||||
|
assert "keine Quellenangabe" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_excerpt_is_finished(work, tmp_path, monkeypatch):
|
||||||
|
(work / "rede_ab_00-05-00.korrigiert.txt").write_text("[00:05:00 - 00:06:00]\n\nB.\n")
|
||||||
|
run_main(monkeypatch, work, "--ja")
|
||||||
|
assert (tmp_path / "fertig" / "rede.txt").is_file()
|
||||||
|
assert (tmp_path / "fertig" / "rede_ab_00-05-00.txt").read_text().endswith(
|
||||||
|
"[00:05:00 - 00:06:00]\n\nB.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_foreign_file_prevents_deletion(work, tmp_path, monkeypatch, capsys):
|
||||||
|
(work / "notizen.docx").write_bytes(b"x")
|
||||||
|
run_main(monkeypatch, work, "--ja")
|
||||||
|
assert work.is_dir()
|
||||||
|
assert (tmp_path / "fertig" / "rede.txt").is_file()
|
||||||
|
assert "Fremde Datei: notizen.docx" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("protected", ["testdaten", "glossare", "tests", "fertig"])
|
||||||
|
def test_protected_folders_are_never_deleted(tmp_path, monkeypatch, capsys, protected):
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
folder = tmp_path / protected / "rede"
|
||||||
|
folder.mkdir(parents=True)
|
||||||
|
(folder / "rede.korrigiert.txt").write_text(TEXT, encoding="utf-8")
|
||||||
|
run_main(monkeypatch, folder, "--ja", "--ziel", tmp_path / "ziel")
|
||||||
|
assert folder.is_dir()
|
||||||
|
assert f"„{protected}“, das nie gelöscht wird" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_current_directory_is_never_deleted(tmp_path, monkeypatch, capsys):
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
(tmp_path / "rede.korrigiert.txt").write_text(TEXT, encoding="utf-8")
|
||||||
|
run_main(monkeypatch, ".", "--ja")
|
||||||
|
assert (tmp_path / "rede.korrigiert.txt").is_file()
|
||||||
|
assert "Aufrufverzeichnis" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_video_glossary_entries_not_shared_are_listed(work, tmp_path, monkeypatch, capsys):
|
||||||
|
(work / "rede.glossar.txt").write_text(
|
||||||
|
"Kremmel => Kreml\nNetano => Netanjahu\nEgon Bahr\nMoskau\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
(tmp_path / "glossare").mkdir()
|
||||||
|
(tmp_path / "glossare" / "gemeinsam.glossar.txt").write_text(
|
||||||
|
"Kremmel => Kreml\nMoskau\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
run_main(monkeypatch, work, "--ja")
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
listed = out.split("gemeinsames übernehmen:")[1].split("\n\n")[0]
|
||||||
|
assert "Netano => Netanjahu" in listed and "Egon Bahr" in listed
|
||||||
|
assert "Kremmel" not in listed and "Moskau" not in listed
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_folder(tmp_path, monkeypatch, capsys):
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
run_main(monkeypatch, tmp_path / "fehlt")
|
||||||
|
assert "Ordner nicht gefunden" in capsys.readouterr().err
|
||||||
|
|
@ -14,7 +14,8 @@ REPO = Path(__file__).resolve().parent.parent
|
||||||
# Own, made-up sample speech; real speeches are only kept locally in testdaten/
|
# Own, made-up sample speech; real speeches are only kept locally in testdaten/
|
||||||
SAMPLE = REPO / "tests" / "daten" / "beispiel.de.txt"
|
SAMPLE = REPO / "tests" / "daten" / "beispiel.de.txt"
|
||||||
GLOSSARY = REPO / "tests" / "daten" / "beispiel.glossar.txt"
|
GLOSSARY = REPO / "tests" / "daten" / "beispiel.glossar.txt"
|
||||||
# Real glossaries and speeches are only kept locally (see .gitignore)
|
# Real glossaries and speeches are only kept locally (see .gitignore): shared
|
||||||
|
# glossaries in glossare/, each speech with its glossary in testdaten/<rede>/
|
||||||
GLOSSARIES = REPO / "glossare"
|
GLOSSARIES = REPO / "glossare"
|
||||||
SHARED_GLOSSARY = GLOSSARIES / "krone-schmalz.glossar.txt"
|
SHARED_GLOSSARY = GLOSSARIES / "krone-schmalz.glossar.txt"
|
||||||
SPEECHES = REPO / "testdaten"
|
SPEECHES = REPO / "testdaten"
|
||||||
|
|
@ -127,17 +128,18 @@ def test_build_system_appends_terms():
|
||||||
assert "keep only the surname" in ak.SYSTEM_EN
|
assert "keep only the surname" in ak.SYSTEM_EN
|
||||||
|
|
||||||
|
|
||||||
|
# testdaten/<rede>/: raw text and the glossary of that speech
|
||||||
SAMPLE_GLOSSARIES = [
|
SAMPLE_GLOSSARIES = [
|
||||||
("ramstein.glossar.txt", "ramstein.de.txt"),
|
("ramstein/ramstein.glossar.txt", "ramstein/ramstein.de.txt"),
|
||||||
("magdeburg.glossar.txt", "magdeburg.de-orig.txt"),
|
("magdeburg/magdeburg.glossar.txt", "magdeburg/magdeburg.de-orig.txt"),
|
||||||
("lahnstein.glossar.txt", "Lahnstein.de-orig.txt"),
|
("lahnstein/lahnstein.glossar.txt", "lahnstein/Lahnstein.de-orig.txt"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
# Real speeches and their glossaries are not in the repository; without them these skip
|
# Real speeches and their glossaries are not in the repository; without them these skip
|
||||||
needs_speeches = pytest.mark.skipif(
|
needs_speeches = pytest.mark.skipif(
|
||||||
not all(
|
not all(
|
||||||
(SPEECHES / sample).is_file() and (GLOSSARIES / glossary).is_file()
|
(SPEECHES / sample).is_file() and (SPEECHES / glossary).is_file()
|
||||||
for glossary, sample in SAMPLE_GLOSSARIES
|
for glossary, sample in SAMPLE_GLOSSARIES
|
||||||
) or not SHARED_GLOSSARY.is_file(),
|
) or not SHARED_GLOSSARY.is_file(),
|
||||||
reason="echte Reden (testdaten/) und ihre Glossare nur lokal",
|
reason="echte Reden (testdaten/) und ihre Glossare nur lokal",
|
||||||
|
|
@ -157,7 +159,7 @@ def test_sample_glossary_replacements_all_match(capsys, glossary, sample):
|
||||||
text = flat((SPEECHES / sample).read_text(encoding="utf-8"))
|
text = flat((SPEECHES / sample).read_text(encoding="utf-8"))
|
||||||
text = ak.apply_replacements(text, ak.read_glossary(SHARED_GLOSSARY)[1])
|
text = ak.apply_replacements(text, ak.read_glossary(SHARED_GLOSSARY)[1])
|
||||||
capsys.readouterr()
|
capsys.readouterr()
|
||||||
ak.apply_replacements(text, ak.read_glossary(GLOSSARIES / glossary)[1])
|
ak.apply_replacements(text, ak.read_glossary(SPEECHES / glossary)[1])
|
||||||
assert "ohne Treffer" not in capsys.readouterr().out
|
assert "ohne Treffer" not in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -636,13 +638,63 @@ def kreml_glossary(folder):
|
||||||
|
|
||||||
def test_main_url_default_names(from_url, tmp_path, monkeypatch):
|
def test_main_url_default_names(from_url, tmp_path, monkeypatch):
|
||||||
monkeypatch.chdir(tmp_path)
|
monkeypatch.chdir(tmp_path)
|
||||||
|
work = tmp_path / "abschriften" / "Kanal_Rede_2026-03-28"
|
||||||
run_main(monkeypatch, "-u", URL, "-g", kreml_glossary(tmp_path))
|
run_main(monkeypatch, "-u", URL, "-g", kreml_glossary(tmp_path))
|
||||||
|
|
||||||
raw = (tmp_path / "Kanal_Rede_2026-03-28.txt").read_text(encoding="utf-8")
|
raw = (work / "Kanal_Rede_2026-03-28.txt").read_text(encoding="utf-8")
|
||||||
assert raw == "Der Kremmel ist in Moskau.\nDas ist bekannt.\n"
|
assert raw == "Der Kremmel ist in Moskau.\nDas ist bekannt.\n"
|
||||||
result = (tmp_path / "Kanal_Rede_2026-03-28.korrigiert.txt").read_text(encoding="utf-8")
|
result = (work / "Kanal_Rede_2026-03-28.korrigiert.txt").read_text(encoding="utf-8")
|
||||||
assert result == "Der Kreml ist in Moskau. Das ist bekannt.\n"
|
assert result == "Der Kreml ist in Moskau. Das ist bekannt.\n"
|
||||||
assert (tmp_path / "Kanal_Rede_2026-03-28.aenderungen.html").is_file()
|
assert (work / "Kanal_Rede_2026-03-28.aenderungen.html").is_file()
|
||||||
|
source = json.loads((work / "Kanal_Rede_2026-03-28.quelle.json").read_text(encoding="utf-8"))
|
||||||
|
assert source["titel"] == "Rede" and source["kanal"] == "Kanal"
|
||||||
|
assert source["datum"] == "20260328"
|
||||||
|
assert source["url"] == "https://www.youtube.com/watch?v=abc123"
|
||||||
|
|
||||||
|
|
||||||
|
def video_folder(tmp_path):
|
||||||
|
"""abschriften/rede/ with raw text and its video glossary."""
|
||||||
|
folder = tmp_path / "abschriften" / "rede"
|
||||||
|
folder.mkdir(parents=True)
|
||||||
|
(folder / "rede.txt").write_text("Der Kremmel ist in Moskau.\n", encoding="utf-8")
|
||||||
|
(folder / "rede.glossar.txt").write_text("Kremmel => Kreml\n", encoding="utf-8")
|
||||||
|
return folder
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_uses_video_glossary(tmp_path, monkeypatch, capsys):
|
||||||
|
folder = video_folder(tmp_path)
|
||||||
|
monkeypatch.setattr(ak, "correct_chunk", identity_correct)
|
||||||
|
monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5))
|
||||||
|
run_main(monkeypatch, "-i", folder / "rede.txt")
|
||||||
|
|
||||||
|
result = (folder / "rede.korrigiert.txt").read_text(encoding="utf-8")
|
||||||
|
assert result == "Der Kreml ist in Moskau.\n"
|
||||||
|
assert "rede.glossar.txt (Video-Glossar, automatisch)" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_video_glossary_given_explicitly_counts_once(tmp_path, monkeypatch, capsys):
|
||||||
|
folder = video_folder(tmp_path)
|
||||||
|
shared = tmp_path / "gemeinsam.glossar.txt"
|
||||||
|
shared.write_text("Moskau\n", encoding="utf-8")
|
||||||
|
monkeypatch.setattr(ak, "correct_chunk", identity_correct)
|
||||||
|
monkeypatch.setattr(ak, "find_paragraph_starts", every_nth_start(5))
|
||||||
|
run_main(monkeypatch, "-i", folder / "rede.txt", "-g", shared, "-g", folder / "rede.glossar.txt")
|
||||||
|
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert out.count("rede.glossar.txt") == 1
|
||||||
|
assert "automatisch" not in out
|
||||||
|
# Shared glossaries first, as given
|
||||||
|
assert out.index("gemeinsam.glossar.txt") < out.index("rede.glossar.txt")
|
||||||
|
|
||||||
|
|
||||||
|
def test_with_video_glossary(tmp_path):
|
||||||
|
folder = video_folder(tmp_path)
|
||||||
|
shared = tmp_path / "gemeinsam.glossar.txt"
|
||||||
|
assert ak.with_video_glossary([shared], folder) == (
|
||||||
|
[shared, folder / "rede.glossar.txt"], folder / "rede.glossar.txt"
|
||||||
|
)
|
||||||
|
# Without a glossary named like the folder nothing is added
|
||||||
|
assert ak.with_video_glossary([shared], tmp_path) == ([shared], None)
|
||||||
|
|
||||||
|
|
||||||
def test_main_url_output_option_and_kept_raw_text(from_url, tmp_path, monkeypatch, capsys):
|
def test_main_url_output_option_and_kept_raw_text(from_url, tmp_path, monkeypatch, capsys):
|
||||||
|
|
@ -762,19 +814,21 @@ def test_cli_errors(sample_copy, monkeypatch, capsys, args, message):
|
||||||
|
|
||||||
def test_main_url_time_range_default_names(from_url, tmp_path, monkeypatch):
|
def test_main_url_time_range_default_names(from_url, tmp_path, monkeypatch):
|
||||||
monkeypatch.chdir(tmp_path)
|
monkeypatch.chdir(tmp_path)
|
||||||
|
work = tmp_path / "abschriften" / "Kanal_Rede_2026-03-28"
|
||||||
run_main(monkeypatch, "-u", URL, "-z", "0:00:02")
|
run_main(monkeypatch, "-u", URL, "-z", "0:00:02")
|
||||||
|
|
||||||
name = "Kanal_Rede_2026-03-28_ab_00-00-02"
|
name = "Kanal_Rede_2026-03-28_ab_00-00-02"
|
||||||
raw = (tmp_path / f"{name}.txt").read_text(encoding="utf-8")
|
raw = (work / f"{name}.txt").read_text(encoding="utf-8")
|
||||||
assert raw == "[00:00:02 - 00:00:03]\nDas ist bekannt.\n"
|
assert raw == "[00:00:02 - 00:00:03]\nDas ist bekannt.\n"
|
||||||
result = (tmp_path / f"{name}.korrigiert.txt").read_text(encoding="utf-8")
|
result = (work / f"{name}.korrigiert.txt").read_text(encoding="utf-8")
|
||||||
assert result == "[00:00:02 - 00:00:03]\n\nDas ist bekannt.\n"
|
assert result == "[00:00:02 - 00:00:03]\n\nDas ist bekannt.\n"
|
||||||
|
|
||||||
|
|
||||||
def test_main_url_range_from_beginning(from_url, tmp_path, monkeypatch):
|
def test_main_url_range_from_beginning(from_url, tmp_path, monkeypatch):
|
||||||
monkeypatch.chdir(tmp_path)
|
monkeypatch.chdir(tmp_path)
|
||||||
|
work = tmp_path / "abschriften" / "Kanal_Rede_2026-03-28"
|
||||||
run_main(monkeypatch, "-u", URL, "-z", "-0:00:02")
|
run_main(monkeypatch, "-u", URL, "-z", "-0:00:02")
|
||||||
raw = (tmp_path / "Kanal_Rede_2026-03-28_bis_00-00-02.txt").read_text(encoding="utf-8")
|
raw = (work / "Kanal_Rede_2026-03-28_bis_00-00-02.txt").read_text(encoding="utf-8")
|
||||||
assert raw == "[00:00:01 - 00:00:02]\nDer Kremmel ist in Moskau.\n"
|
assert raw == "[00:00:01 - 00:00:02]\nDer Kremmel ist in Moskau.\n"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -926,14 +980,15 @@ def test_main_url_several_ranges(fake_yt_dlp, yt_dlp_installed, tmp_path, monkey
|
||||||
monkeypatch.setattr(subprocess, "run", fake_yt_dlp(VIDEO, vtt=MULTI_VTT))
|
monkeypatch.setattr(subprocess, "run", fake_yt_dlp(VIDEO, vtt=MULTI_VTT))
|
||||||
sent = chunk_recorder(monkeypatch)
|
sent = chunk_recorder(monkeypatch)
|
||||||
monkeypatch.chdir(tmp_path)
|
monkeypatch.chdir(tmp_path)
|
||||||
|
work = tmp_path / "abschriften" / "Kanal_Rede_2026-03-28"
|
||||||
run_main(monkeypatch, "-u", URL, "-z", "0:00:00-0:00:30 0:02", "-g", kreml_glossary(tmp_path))
|
run_main(monkeypatch, "-u", URL, "-z", "0:00:00-0:00:30 0:02", "-g", kreml_glossary(tmp_path))
|
||||||
|
|
||||||
name = "Kanal_Rede_2026-03-28_bis_00-00-30+ab_00-02-00"
|
name = "Kanal_Rede_2026-03-28_bis_00-00-30+ab_00-02-00"
|
||||||
assert (tmp_path / f"{name}.txt").read_text(encoding="utf-8") == (
|
assert (work / f"{name}.txt").read_text(encoding="utf-8") == (
|
||||||
"[00:00:01 - 00:00:02]\nDer Kremmel ist in Moskau.\n\n"
|
"[00:00:01 - 00:00:02]\nDer Kremmel ist in Moskau.\n\n"
|
||||||
"[00:02:00 - 00:02:01]\nDas ist bekannt.\n"
|
"[00:02:00 - 00:02:01]\nDas ist bekannt.\n"
|
||||||
)
|
)
|
||||||
assert (tmp_path / f"{name}.korrigiert.txt").read_text(encoding="utf-8") == (
|
assert (work / f"{name}.korrigiert.txt").read_text(encoding="utf-8") == (
|
||||||
"[00:00:01 - 00:00:02]\n\nDer Kreml ist in Moskau.\n\n"
|
"[00:00:01 - 00:00:02]\n\nDer Kreml ist in Moskau.\n\n"
|
||||||
"[00:02:00 - 00:02:01]\n\nDas ist bekannt.\n"
|
"[00:02:00 - 00:02:01]\n\nDas ist bekannt.\n"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -321,3 +321,19 @@ def test_report_and_summary_put_word_forms_last(tmp_path, capsys):
|
||||||
out = capsys.readouterr().out.split("Zu prüfen: ")[1]
|
out = capsys.readouterr().out.split("Zu prüfen: ")[1]
|
||||||
assert out.startswith("2 Stellen (davon 1 Wortformen)")
|
assert out.startswith("2 Stellen (davon 1 Wortformen)")
|
||||||
assert out.index("Professorin") < out.index("„das“ → „dass“")
|
assert out.index("Professorin") < out.index("„das“ → „dass“")
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_uses_video_glossary(tmp_path, monkeypatch, capsys):
|
||||||
|
folder = tmp_path / "rede"
|
||||||
|
folder.mkdir()
|
||||||
|
raw = folder / "rede.txt"
|
||||||
|
raw.write_text("das Kremmel und\nBar heute\n", encoding="utf-8")
|
||||||
|
corrected = folder / "rede.korrigiert.txt"
|
||||||
|
corrected.write_text("Das Kreml und Bahr heute.\n", encoding="utf-8")
|
||||||
|
(folder / "rede.glossar.txt").write_text("Kremmel => Kreml\nEgon Bahr\n", encoding="utf-8")
|
||||||
|
|
||||||
|
monkeypatch.setattr(sys, "argv", ["aenderungen.py", "-a", str(raw), "-b", str(corrected)])
|
||||||
|
ae.main()
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "Video-Glossar (automatisch)" in out
|
||||||
|
assert "Zu prüfen: 0 Stellen" in out
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
"""Tests for extract_text.py; yt-dlp is always replaced by fakes (no network)."""
|
"""Tests for extract_text.py; yt-dlp is always replaced by fakes (no network)."""
|
||||||
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -38,14 +39,19 @@ def test_default_language_is_original_audio(fake_yt_dlp, tmp_path, monkeypatch,
|
||||||
monkeypatch.chdir(tmp_path)
|
monkeypatch.chdir(tmp_path)
|
||||||
run_main(monkeypatch, "-u", URL)
|
run_main(monkeypatch, "-u", URL)
|
||||||
|
|
||||||
text = (tmp_path / "Kanal_Eine_Rede_2026-03-28.txt").read_text(encoding="utf-8")
|
work = tmp_path / "abschriften" / "Kanal_Eine_Rede_2026-03-28"
|
||||||
|
text = (work / "Kanal_Eine_Rede_2026-03-28.txt").read_text(encoding="utf-8")
|
||||||
assert text == "Guten Abend.\nSchön, dass Sie da sind.\n"
|
assert text == "Guten Abend.\nSchön, dass Sie da sind.\n"
|
||||||
assert "de-orig" in run.calls[1]
|
assert "de-orig" in run.calls[1]
|
||||||
assert "--write-auto-subs" in run.calls[1]
|
assert "--write-auto-subs" in run.calls[1]
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "Kanal_Eine_Rede_2026-03-28.txt (2 Zeilen, 7 Wörter)" in out
|
assert "Kanal_Eine_Rede_2026-03-28.txt (2 Zeilen, 7 Wörter)" in out
|
||||||
# Only the text file remains, no VTT
|
# Only the text file and where it comes from remain, no VTT
|
||||||
assert [p.name for p in tmp_path.iterdir()] == ["Kanal_Eine_Rede_2026-03-28.txt"]
|
assert sorted(p.name for p in work.iterdir()) == [
|
||||||
|
"Kanal_Eine_Rede_2026-03-28.quelle.json", "Kanal_Eine_Rede_2026-03-28.txt",
|
||||||
|
]
|
||||||
|
source = json.loads((work / "Kanal_Eine_Rede_2026-03-28.quelle.json").read_text())
|
||||||
|
assert source["kanal"] == "Kanal" and source["url"].endswith("v=abc123")
|
||||||
|
|
||||||
|
|
||||||
def test_default_language_ignores_dubbed_tracks(fake_yt_dlp, tmp_path, monkeypatch):
|
def test_default_language_ignores_dubbed_tracks(fake_yt_dlp, tmp_path, monkeypatch):
|
||||||
|
|
@ -133,7 +139,8 @@ def test_time_range_default_name(fake_yt_dlp, tmp_path, monkeypatch):
|
||||||
run_main(monkeypatch, "-u", URL, "-z", "0:00:02-0:00:03")
|
run_main(monkeypatch, "-u", URL, "-z", "0:00:02-0:00:03")
|
||||||
|
|
||||||
name = "Kanal_Eine_Rede_2026-03-28_00-00-02_bis_00-00-03.txt"
|
name = "Kanal_Eine_Rede_2026-03-28_00-00-02_bis_00-00-03.txt"
|
||||||
assert (tmp_path / name).read_text(encoding="utf-8") == (
|
work = tmp_path / "abschriften" / "Kanal_Eine_Rede_2026-03-28"
|
||||||
|
assert (work / name).read_text(encoding="utf-8") == (
|
||||||
"[00:00:02 - 00:00:03]\nSchön, dass Sie da sind.\n"
|
"[00:00:02 - 00:00:03]\nSchön, dass Sie da sind.\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -183,7 +190,8 @@ def test_several_ranges_flow_text_keeps_headers(fake_yt_dlp, tmp_path, monkeypat
|
||||||
run_main(monkeypatch, "-u", URL, "-z", "0:00:00-0:00:30 0:02", "-f")
|
run_main(monkeypatch, "-u", URL, "-z", "0:00:00-0:00:30 0:02", "-f")
|
||||||
|
|
||||||
name = "Kanal_Eine_Rede_2026-03-28_bis_00-00-30+ab_00-02-00.txt"
|
name = "Kanal_Eine_Rede_2026-03-28_bis_00-00-30+ab_00-02-00.txt"
|
||||||
assert (tmp_path / name).read_text(encoding="utf-8") == (
|
work = tmp_path / "abschriften" / "Kanal_Eine_Rede_2026-03-28"
|
||||||
|
assert (work / name).read_text(encoding="utf-8") == (
|
||||||
"[00:00:01 - 00:00:02]\nErster Satz der\nRede.\n\n"
|
"[00:00:01 - 00:00:02]\nErster Satz der\nRede.\n\n"
|
||||||
"[00:02:00 - 00:02:01]\nDritter Satz, der\netwas länger ist.\n"
|
"[00:02:00 - 00:02:01]\nDritter Satz, der\netwas länger ist.\n"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -160,3 +160,18 @@ def test_time_headers_never_reach_model_or_changes(tmp_path, monkeypatch):
|
||||||
draft = out.read_text(encoding="utf-8")
|
draft = out.read_text(encoding="utf-8")
|
||||||
assert "# ? Kremmel => Kreml (1×)" in draft
|
assert "# ? Kremmel => Kreml (1×)" in draft
|
||||||
assert "00:0" not in draft.replace(str(raw), "")
|
assert "00:0" not in draft.replace(str(raw), "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_uses_video_glossary(tmp_path, monkeypatch, capsys):
|
||||||
|
folder = tmp_path / "rede"
|
||||||
|
folder.mkdir()
|
||||||
|
raw = folder / "rede.txt"
|
||||||
|
raw.write_text(RAW + "\n", encoding="utf-8")
|
||||||
|
(folder / "rede.glossar.txt").write_text("Kremmel => Kreml\n", encoding="utf-8")
|
||||||
|
model = fake_model(ENTRIES)
|
||||||
|
monkeypatch.setattr(urllib.request, "urlopen", model)
|
||||||
|
run_main(monkeypatch, "-i", raw)
|
||||||
|
|
||||||
|
sent = model.calls[0]["messages"][1]["content"]
|
||||||
|
assert "Kreml" in sent and "Kremmel" not in sent
|
||||||
|
assert "Video-Glossar (automatisch)" in capsys.readouterr().out
|
||||||
|
|
|
||||||
|
|
@ -124,8 +124,8 @@ def speech(name):
|
||||||
|
|
||||||
@pytest.mark.parametrize("vtt_path", [
|
@pytest.mark.parametrize("vtt_path", [
|
||||||
SAMPLE_VTT,
|
SAMPLE_VTT,
|
||||||
speech("ramstein.de.vtt"), speech("magdeburg.de-orig.vtt"),
|
speech("ramstein/ramstein.de.vtt"), speech("magdeburg/magdeburg.de-orig.vtt"),
|
||||||
speech("Lahnstein.de-orig.vtt"),
|
speech("lahnstein/Lahnstein.de-orig.vtt"),
|
||||||
])
|
])
|
||||||
def test_sample_vtt_gives_sample_txt(vtt_path):
|
def test_sample_vtt_gives_sample_txt(vtt_path):
|
||||||
vtt = vtt_path.read_text(encoding="utf-8-sig")
|
vtt = vtt_path.read_text(encoding="utf-8-sig")
|
||||||
|
|
|
||||||
|
|
@ -140,8 +140,11 @@ def test_yt_dlp_runs_in_temporary_directory(fake_yt_dlp, tmp_path, monkeypatch):
|
||||||
|
|
||||||
assert all(cwd is not None and Path(cwd) != tmp_path for cwd in run.cwds)
|
assert all(cwd is not None and Path(cwd) != tmp_path for cwd in run.cwds)
|
||||||
template = run.calls[1][run.calls[1].index("-o") + 1]
|
template = run.calls[1][run.calls[1].index("-o") + 1]
|
||||||
assert template == f"{tmp_path / 'Rede'}.%(ext)s"
|
# Without -d: working folder abschriften/<Name>/, named after -n
|
||||||
assert (tmp_path / "Rede.de-orig.vtt").is_file()
|
work = tmp_path / "abschriften" / "Rede"
|
||||||
|
assert template == f"{work / 'Rede'}.%(ext)s"
|
||||||
|
assert (work / "Rede.de-orig.vtt").is_file()
|
||||||
|
assert (work / "Rede.quelle.json").is_file()
|
||||||
|
|
||||||
|
|
||||||
def test_main_prefers_manual_and_default_name(fake_yt_dlp, tmp_path, monkeypatch):
|
def test_main_prefers_manual_and_default_name(fake_yt_dlp, tmp_path, monkeypatch):
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Lädt die Untertitel eines YouTube-Videos als VTT-Datei herunter (über yt-dlp),
|
"""Lädt die Untertitel eines YouTube-Videos als VTT-Datei herunter (über yt-dlp),
|
||||||
als Eingabe für vtt2txt.py bzw. abschrift_korrigieren.py."""
|
als Eingabe für vtt2txt.py bzw. abschrift_korrigieren.py."""
|
||||||
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
|
@ -14,6 +15,7 @@ YT_DLP = "yt-dlp"
|
||||||
NAME_WORD = re.compile(r"\w+(?:[-.]\w+)*") # keeps "Krone-Schmalz", "28.03.2026"
|
NAME_WORD = re.compile(r"\w+(?:[-.]\w+)*") # keeps "Krone-Schmalz", "28.03.2026"
|
||||||
MAX_CHANNEL_CHARS = 40
|
MAX_CHANNEL_CHARS = 40
|
||||||
MAX_TITLE_CHARS = 60
|
MAX_TITLE_CHARS = 60
|
||||||
|
WORK_DIR = Path("abschriften") # ein Arbeitsordner je Video: abschriften/<Name>/
|
||||||
|
|
||||||
|
|
||||||
def run_yt_dlp(arguments):
|
def run_yt_dlp(arguments):
|
||||||
|
|
@ -112,6 +114,31 @@ def default_name(info):
|
||||||
return "_".join(p for p in parts if p) or info.get("id") or "video"
|
return "_".join(p for p in parts if p) or info.get("id") or "video"
|
||||||
|
|
||||||
|
|
||||||
|
def work_folder(name):
|
||||||
|
"""Working folder of one video: abschriften/<Name>/"""
|
||||||
|
return WORK_DIR / name
|
||||||
|
|
||||||
|
|
||||||
|
def write_source(info, path):
|
||||||
|
"""Store where a transcript comes from (<Name>.quelle.json), for the
|
||||||
|
header of the finished result."""
|
||||||
|
url = info.get("webpage_url") or (
|
||||||
|
f"https://www.youtube.com/watch?v={info['id']}" if info.get("id") else None
|
||||||
|
)
|
||||||
|
source = {
|
||||||
|
"titel": info.get("title"),
|
||||||
|
"kanal": info.get("channel") or info.get("uploader"),
|
||||||
|
"datum": info.get("upload_date"),
|
||||||
|
"url": url,
|
||||||
|
"id": info.get("id"),
|
||||||
|
"dauer": info.get("duration"),
|
||||||
|
"abgerufen": date.today().isoformat(),
|
||||||
|
}
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps(source, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
def available_tracks(info):
|
def available_tracks(info):
|
||||||
"""Manual tracks and the automatic ones usable for this program."""
|
"""Manual tracks and the automatic ones usable for this program."""
|
||||||
manual = sorted((info.get("subtitles") or {}).keys())
|
manual = sorted((info.get("subtitles") or {}).keys())
|
||||||
|
|
@ -164,8 +191,8 @@ def parse_args():
|
||||||
"-l", "--lang", default="de", help="Sprache der Untertitel (Standard: de)"
|
"-l", "--lang", default="de", help="Sprache der Untertitel (Standard: de)"
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-d", "--dir", type=Path, default=Path("."),
|
"-d", "--dir", type=Path,
|
||||||
help="Zielverzeichnis (Standard: aktuelles Verzeichnis)",
|
help="Zielverzeichnis (Standard: Arbeitsordner abschriften/<Name>/)",
|
||||||
)
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
|
@ -189,9 +216,12 @@ def main():
|
||||||
kind = "automatische" if is_auto else "manuelle"
|
kind = "automatische" if is_auto else "manuelle"
|
||||||
print(f"Lade {kind} Untertitel „{track}“ …", flush=True)
|
print(f"Lade {kind} Untertitel „{track}“ …", flush=True)
|
||||||
|
|
||||||
args.dir.mkdir(parents=True, exist_ok=True)
|
name = args.name or default_name(info)
|
||||||
target_base = args.dir / (args.name or default_name(info))
|
folder = args.dir or work_folder(name)
|
||||||
|
folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
target_base = folder / name
|
||||||
download(args.url, track, is_auto, target_base)
|
download(args.url, track, is_auto, target_base)
|
||||||
|
write_source(info, folder / f"{name}.quelle.json")
|
||||||
|
|
||||||
target = Path(f"{target_base}.{track}.vtt")
|
target = Path(f"{target_base}.{track}.vtt")
|
||||||
if not target.is_file():
|
if not target.is_file():
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue