feat: Zeitraum-Auswahl (-s/-e) auch in rede_korrigieren.py und extract_text.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-11 19:07:56 +02:00
commit 82489ee83c
5 changed files with 111 additions and 17 deletions

View file

@ -66,6 +66,7 @@ Zwischenschritt im temporären Verzeichnis. Die Spurwahl ist dieselbe wie bei
# → John_Mearsheimer_Offensive_Realism_Episode_1_Origins_of_the_War_In_Ukraine_2026-07-30.txt
./extract_text.py -u URL -l de -o testdaten/rede.txt # Sprache und Ziel
./extract_text.py -u URL -f -o rede.txt # lesbarer Fließtext
./extract_text.py -u URL -s 3:17 -e 5:25 # nur ein Ausschnitt
./rede_korrigieren.py -i rede.txt # → rede.korrigiert.txt
```
@ -74,6 +75,7 @@ Zwischenschritt im temporären Verzeichnis. Die Spurwahl ist dieselbe wie bei
| `-u`, `--url` | YouTube-URL, Pflicht |
| `-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 |
| `-s`, `--start` / `-e`, `--ende` | nur Untertitel in diesem Zeitraum (`00:03:17`, `3:17` oder `197` Sekunden), wie bei `vtt2txt.py` |
| `-f`, `--fliesstext` | Fließtext, auf 80 Zeichen umbrochen (Wörter werden nie getrennt), statt der kurzen Untertitelzeilen; beides versteht `rede_korrigieren.py` |
### Standard-Dateinamen für YouTube-Videos
@ -89,7 +91,9 @@ Ohne `-o` bzw. `-n` bilden `extract_text.py`, `yt_untertitel.py` und
Wörter werden mit `_` verbunden. Bindestriche und Punkte im Wort bleiben
(„Krone-Schmalz“), andere Zeichen fallen weg. Der Kanal wird auf 40, der Titel auf
60 Zeichen an einer Wortgrenze gekürzt. Ohne verwertbaren Kanal und Titel gilt die
Video-ID. Gleiches Video ergibt immer denselben Namen; eine vorhandene Datei wird
Video-ID. Mit `-s`/`-e` kommt der Zeitraum dazu, z. B.
`…_2026-07-30_00-03-17_bis_00-05-25` (nur `-s`: `_ab_…`, nur `-e`: `_bis_…`).
Gleiches Video ergibt immer denselben Namen; eine vorhandene Datei wird
überschrieben (ein mit `-u` gespeicherter, abweichender Rohtext aber nie).
### Untertitel herunterladen: `yt_untertitel.py`
@ -161,6 +165,7 @@ werden trotzdem umgewandelt; der Exit-Code ist dann 1.
./rede_korrigieren.py -i rede.de.vtt # VTT direkt → rede.de.korrigiert.txt
./rede_korrigieren.py -u URL # YouTube → <Kanal>_<Titel>_<Datum>.korrigiert.txt
./rede_korrigieren.py -i speech.txt -l en # englische Rede
./rede_korrigieren.py -u URL -s 3:17 -e 5:25 # nur ein Ausschnitt des Videos
```
| Option | Bedeutung |
@ -168,7 +173,8 @@ werden trotzdem umgewandelt; der Exit-Code ist dann 1.
| `-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 |
| `-l`, `--lang` | Sprache der Rede: `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` |
| `-o`, `--output` | Ausgabedatei; Standard: `<Eingabe ohne Endung>.korrigiert.txt` neben der Eingabe, mit `-u` `<Kanal>_<Titel>_<Datum>.korrigiert.txt`; mit `-s`/`-e` zusätzlich der Zeitraum im Namen |
| `-s`, `--start` / `-e`, `--ende` | nur diesen Zeitraum korrigieren (`00:03:17`, `3:17` oder `197` Sekunden); nur mit `.vtt`-Eingabe oder `-u`, da reiner Text keine Zeitstempel hat |
| `-g`, `--glossar` | Glossar mit festen Ersetzungen und Schreibweisen; mehrfach angeben zum Kombinieren (Ersetzungen in Reihenfolge der Angabe) |
| `-m`, `--model` | Ollama-Modell; Standard: `qwen3:32b` (Konstante `MODEL`) |
| `--kein-bericht` | keinen Änderungsbericht schreiben |

View file

@ -7,7 +7,7 @@ import shutil
import tempfile
import textwrap
from vtt2txt import vtt_to_lines
from vtt2txt import add_time_options, check_time_range, time_suffix, vtt_to_lines
from yt_untertitel import (
YT_DLP, choose_track, default_name, download, fetch_info, no_track_message,
original_language,
@ -27,13 +27,16 @@ def parse_args():
)
parser.add_argument(
"-o", "--output", type=Path,
help="Ausgabedatei (Standard: <Kanal>_<Titel>_<Datum>.txt)",
help="Ausgabedatei (Standard: <Kanal>_<Titel>_<Datum>.txt, "
"mit -s/-e zusätzlich der Zeitraum)",
)
parser.add_argument(
"-f", "--fliesstext", action="store_true",
help=f"Fließtext, auf {FLOW_WIDTH} Zeichen umbrochen, statt Untertitelzeilen",
)
add_time_options(parser)
args = parser.parse_args()
check_time_range(parser, args)
if args.output is not None and args.output.is_dir():
parser.error(f"-o/--output ist ein Verzeichnis: {args.output}")
@ -42,10 +45,11 @@ def parse_args():
return args
def fetch_text(url, lang=None):
def fetch_text(url, lang=None, start=None, end=None):
"""Download the subtitles of a video; return (video info, text lines).
Without `lang` the language of the original audio is used.
Without `lang` the language of the original audio is used. `start`/`end`
(seconds) keep only the captions of that time range.
"""
info = fetch_info(url)
print(f"Video: {info.get('title', '?')}")
@ -65,17 +69,20 @@ def fetch_text(url, lang=None):
vtt = Path(f"{target_base}.{track}.vtt")
if not vtt.is_file():
raise SystemExit("yt-dlp hat keine Untertitel-Datei geschrieben.")
lines = vtt_to_lines(vtt.read_text(encoding="utf-8-sig"))
lines = vtt_to_lines(vtt.read_text(encoding="utf-8-sig"), start, end)
if not lines:
if start is None and end is None:
raise SystemExit("Die Untertitel enthalten keinen Text.")
raise SystemExit("Die Untertitel enthalten im gewählten Zeitraum keinen Text.")
return info, lines
def main():
args = parse_args()
info, lines = fetch_text(args.url, args.lang)
output = args.output or Path(f"{default_name(info)}.txt")
info, lines = fetch_text(args.url, args.lang, args.start, args.ende)
suffix = time_suffix(args.start, args.ende)
output = args.output or Path(f"{default_name(info)}{suffix}.txt")
if args.fliesstext:
# Never split words, not even at hyphens ("EU-Gipfel")

View file

@ -12,7 +12,7 @@ import urllib.request
from aenderungen import REVIEW_MARK, print_summary, report_path, write_report
from extract_text import fetch_text
from vtt2txt import vtt_to_lines
from vtt2txt import add_time_options, check_time_range, time_suffix, vtt_to_lines
from yt_untertitel import YT_DLP, base_language, default_name, original_language
MODEL = "qwen3:32b"
@ -452,7 +452,8 @@ def parse_args():
parser.add_argument(
"-o", "--output", type=Path,
help="Ausgabedatei (Standard: <Eingabe ohne Endung>.korrigiert.txt, "
"mit -u <Kanal>_<Titel>_<Datum>.korrigiert.txt)",
"mit -u <Kanal>_<Titel>_<Datum>.korrigiert.txt; mit -s/-e zusätzlich "
"der Zeitraum)",
)
parser.add_argument(
"-g", "--glossar", type=Path, action="append", default=[],
@ -466,7 +467,10 @@ def parse_args():
"--kein-bericht", action="store_true",
help="keinen Änderungsbericht <Ausgabe>.aenderungen.html schreiben",
)
add_time_options(parser)
args = parser.parse_args()
check_time_range(parser, args)
timed = args.start is not None or args.ende is not None
for glossary in args.glossar:
if not glossary.is_file():
@ -483,8 +487,11 @@ def parse_args():
if not args.input.is_file():
parser.error(f"Eingabedatei nicht gefunden: {args.input}")
if timed and args.input.suffix.lower() != ".vtt":
parser.error("-s/-e brauchen Zeitstempel: nur mit .vtt-Eingabe oder -u.")
if args.output is None:
args.output = args.input.with_name(f"{args.input.stem}.korrigiert.txt")
suffix = time_suffix(args.start, args.ende)
args.output = args.input.with_name(f"{args.input.stem}{suffix}.korrigiert.txt")
if args.output.resolve() == args.input.resolve():
parser.error("Ausgabedatei darf nicht die Eingabedatei sein.")
return args
@ -510,20 +517,21 @@ def save_raw_text(path, raw):
def read_source(args):
"""Return the raw transcript, the output path and the language of the speech."""
if args.url is not None:
info, lines = fetch_text(args.url, args.lang)
info, lines = fetch_text(args.url, args.lang, args.start, args.ende)
lang = base_language(args.lang or original_language(info) or DEFAULT_LANGUAGE)
if lang not in PROMPTS:
raise SystemExit(
f"Die Rede ist auf „{lang}“; unterstützt: {', '.join(PROMPTS)} (-l)."
)
target = args.output or Path(f"{default_name(info)}.korrigiert.txt")
suffix = time_suffix(args.start, args.ende)
target = args.output or Path(f"{default_name(info)}{suffix}.korrigiert.txt")
raw = "\n".join(lines) + "\n"
save_raw_text(raw_text_path(target), raw)
return raw, target, lang
raw = args.input.read_text(encoding="utf-8-sig")
if args.input.suffix.lower() == ".vtt":
raw = "\n".join(vtt_to_lines(raw))
raw = "\n".join(vtt_to_lines(raw, args.start, args.ende))
return raw, args.output, base_language(args.lang or DEFAULT_LANGUAGE)
@ -560,7 +568,9 @@ def main():
chunks = split_chunks(text)
if not chunks:
if args.start is None and args.ende is None:
raise SystemExit("Die Eingabedatei ist leer.")
raise SystemExit("Die Eingabedatei enthält im gewählten Zeitraum keinen Text.")
parts_dir.mkdir(parents=True, exist_ok=True)
results = []

View file

@ -125,3 +125,25 @@ def test_cli_errors(tmp_path, monkeypatch, capsys, args, message):
run_main(monkeypatch, *args)
assert exc.value.code == 2
assert message in capsys.readouterr().err
def test_time_range_default_name(fake_yt_dlp, tmp_path, monkeypatch):
monkeypatch.setattr(yt.subprocess, "run", fake_yt_dlp(GERMAN_VIDEO, vtt=VTT))
monkeypatch.chdir(tmp_path)
run_main(monkeypatch, "-u", URL, "-s", "2", "-e", "0:03")
name = "Kanal_Eine_Rede_2026-03-28_00-00-02_bis_00-00-03.txt"
assert (tmp_path / name).read_text(encoding="utf-8") == "Schön, dass Sie da sind.\n"
def test_time_range_without_text(fake_yt_dlp, tmp_path, monkeypatch):
monkeypatch.setattr(yt.subprocess, "run", fake_yt_dlp(GERMAN_VIDEO, vtt=VTT))
with pytest.raises(SystemExit, match="im gewählten Zeitraum keinen Text"):
run_main(monkeypatch, "-u", URL, "-s", "1:00", "-o", tmp_path / "out.txt")
def test_time_range_order_error(monkeypatch, capsys):
with pytest.raises(SystemExit) as exc:
run_main(monkeypatch, "-u", URL, "-s", "2:00", "-e", "1:00")
assert exc.value.code == 2
assert "muss vor -e/--ende liegen" in capsys.readouterr().err

View file

@ -517,3 +517,52 @@ def test_cli_errors(sample_copy, monkeypatch, capsys, args, message):
run_main(monkeypatch, *args)
assert exc.value.code == 2
assert message in capsys.readouterr().err
# === Time range (-s/-e) ===
def test_main_url_time_range_default_names(from_url, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
run_main(monkeypatch, "-u", URL, "-s", "0:02")
name = "Kanal_Rede_2026-03-28_ab_00-00-02"
raw = (tmp_path / f"{name}.txt").read_text(encoding="utf-8")
assert raw == "Das ist bekannt.\n"
assert (tmp_path / f"{name}.korrigiert.txt").is_file()
def test_main_url_time_range_without_text(from_url, tmp_path, monkeypatch):
with pytest.raises(SystemExit, match="im gewählten Zeitraum keinen Text"):
run_main(monkeypatch, "-u", URL, "-s", "1:00", "-o", tmp_path / "x.korrigiert.txt")
def test_main_vtt_time_range(tmp_path, monkeypatch):
vtt = tmp_path / "rede.de.vtt"
vtt.write_text(VTT, encoding="utf-8")
monkeypatch.setattr(rk, "correct_chunk", identity_correct)
monkeypatch.setattr(rk, "find_paragraph_starts", every_nth_start(5))
run_main(monkeypatch, "-i", vtt, "-e", "2")
result = tmp_path / "rede.de_bis_00-00-02.korrigiert.txt"
assert result.read_text(encoding="utf-8") == "Der Kremmel ist in Moskau.\n"
def test_main_vtt_time_range_without_text(tmp_path, monkeypatch):
vtt = tmp_path / "rede.de.vtt"
vtt.write_text(VTT, encoding="utf-8")
monkeypatch.setattr(rk, "correct_chunk", no_model_calls)
with pytest.raises(SystemExit, match="im gewählten Zeitraum keinen Text"):
run_main(monkeypatch, "-i", vtt, "-s", "1:00")
@pytest.mark.parametrize("args, message", [
(["-i", "{sample}", "-s", "1:00"], "nur mit .vtt-Eingabe oder -u"),
(["-i", "{sample}", "-s", "x"], "ungültige Zeit"),
(["-i", "{sample}", "-s", "2:00", "-e", "1:00"], "muss vor -e/--ende liegen"),
])
def test_cli_time_errors(sample_copy, monkeypatch, capsys, args, message):
args = [a.format(sample=sample_copy) for a in args]
with pytest.raises(SystemExit) as exc:
run_main(monkeypatch, *args)
assert exc.value.code == 2
assert message in capsys.readouterr().err