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 # → 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 -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 -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 ./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 | | `-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: `<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` | | `-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 ### 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 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 („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 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). überschrieben (ein mit `-u` gespeicherter, abweichender Rohtext aber nie).
### Untertitel herunterladen: `yt_untertitel.py` ### 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 -i rede.de.vtt # VTT direkt → rede.de.korrigiert.txt
./rede_korrigieren.py -u URL # YouTube → <Kanal>_<Titel>_<Datum>.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 -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 | | 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) | | `-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 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 | | `-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) | | `-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`) | | `-m`, `--model` | Ollama-Modell; Standard: `qwen3:32b` (Konstante `MODEL`) |
| `--kein-bericht` | keinen Änderungsbericht schreiben | | `--kein-bericht` | keinen Änderungsbericht schreiben |

View file

@ -7,7 +7,7 @@ import shutil
import tempfile import tempfile
import textwrap 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 ( 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,
@ -27,13 +27,16 @@ 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: <Kanal>_<Titel>_<Datum>.txt, "
"mit -s/-e zusätzlich der Zeitraum)",
) )
parser.add_argument( parser.add_argument(
"-f", "--fliesstext", action="store_true", "-f", "--fliesstext", action="store_true",
help=f"Fließtext, auf {FLOW_WIDTH} Zeichen umbrochen, statt Untertitelzeilen", help=f"Fließtext, auf {FLOW_WIDTH} Zeichen umbrochen, statt Untertitelzeilen",
) )
add_time_options(parser)
args = parser.parse_args() args = parser.parse_args()
check_time_range(parser, args)
if args.output is not None and args.output.is_dir(): if args.output is not None and args.output.is_dir():
parser.error(f"-o/--output ist ein Verzeichnis: {args.output}") parser.error(f"-o/--output ist ein Verzeichnis: {args.output}")
@ -42,10 +45,11 @@ def parse_args():
return 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). """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) info = fetch_info(url)
print(f"Video: {info.get('title', '?')}") print(f"Video: {info.get('title', '?')}")
@ -65,17 +69,20 @@ def fetch_text(url, lang=None):
vtt = Path(f"{target_base}.{track}.vtt") vtt = Path(f"{target_base}.{track}.vtt")
if not vtt.is_file(): if not vtt.is_file():
raise SystemExit("yt-dlp hat keine Untertitel-Datei geschrieben.") 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 not lines:
if start is None and end is None:
raise SystemExit("Die Untertitel enthalten keinen Text.") raise SystemExit("Die Untertitel enthalten keinen Text.")
raise SystemExit("Die Untertitel enthalten im gewählten Zeitraum keinen Text.")
return info, lines return info, lines
def main(): def main():
args = parse_args() args = parse_args()
info, lines = fetch_text(args.url, args.lang) info, lines = fetch_text(args.url, args.lang, args.start, args.ende)
output = args.output or Path(f"{default_name(info)}.txt") suffix = time_suffix(args.start, args.ende)
output = args.output or Path(f"{default_name(info)}{suffix}.txt")
if args.fliesstext: if args.fliesstext:
# Never split words, not even at hyphens ("EU-Gipfel") # 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 aenderungen import REVIEW_MARK, print_summary, report_path, write_report
from extract_text import fetch_text 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 from yt_untertitel import YT_DLP, base_language, default_name, original_language
MODEL = "qwen3:32b" MODEL = "qwen3:32b"
@ -452,7 +452,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 -u <Kanal>_<Titel>_<Datum>.korrigiert.txt; mit -s/-e zusätzlich "
"der Zeitraum)",
) )
parser.add_argument( parser.add_argument(
"-g", "--glossar", type=Path, action="append", default=[], "-g", "--glossar", type=Path, action="append", default=[],
@ -466,7 +467,10 @@ def parse_args():
"--kein-bericht", action="store_true", "--kein-bericht", action="store_true",
help="keinen Änderungsbericht <Ausgabe>.aenderungen.html schreiben", help="keinen Änderungsbericht <Ausgabe>.aenderungen.html schreiben",
) )
add_time_options(parser)
args = parser.parse_args() 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: for glossary in args.glossar:
if not glossary.is_file(): if not glossary.is_file():
@ -483,8 +487,11 @@ def parse_args():
if not args.input.is_file(): if not args.input.is_file():
parser.error(f"Eingabedatei nicht gefunden: {args.input}") 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: 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(): if args.output.resolve() == args.input.resolve():
parser.error("Ausgabedatei darf nicht die Eingabedatei sein.") parser.error("Ausgabedatei darf nicht die Eingabedatei sein.")
return args return args
@ -510,20 +517,21 @@ def save_raw_text(path, raw):
def read_source(args): def read_source(args):
"""Return the raw transcript, the output path and the language of the speech.""" """Return the raw transcript, the output path and the language of the speech."""
if args.url is not None: 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) lang = base_language(args.lang or original_language(info) or DEFAULT_LANGUAGE)
if lang not in PROMPTS: if lang not in PROMPTS:
raise SystemExit( raise SystemExit(
f"Die Rede ist auf „{lang}“; unterstützt: {', '.join(PROMPTS)} (-l)." 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" raw = "\n".join(lines) + "\n"
save_raw_text(raw_text_path(target), raw) save_raw_text(raw_text_path(target), raw)
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")
if args.input.suffix.lower() == ".vtt": 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) return raw, args.output, base_language(args.lang or DEFAULT_LANGUAGE)
@ -560,7 +568,9 @@ def main():
chunks = split_chunks(text) chunks = split_chunks(text)
if not chunks: if not chunks:
if args.start is None and args.ende is None:
raise SystemExit("Die Eingabedatei ist leer.") 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) parts_dir.mkdir(parents=True, exist_ok=True)
results = [] results = []

View file

@ -125,3 +125,25 @@ def test_cli_errors(tmp_path, monkeypatch, capsys, args, message):
run_main(monkeypatch, *args) run_main(monkeypatch, *args)
assert exc.value.code == 2 assert exc.value.code == 2
assert message in capsys.readouterr().err 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) run_main(monkeypatch, *args)
assert exc.value.code == 2 assert exc.value.code == 2
assert message in capsys.readouterr().err 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