diff --git a/README.md b/README.md index 3c445fb..3003bcb 100644 --- a/README.md +++ b/README.md @@ -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 (`-orig`) | | `-o`, `--output` | Ausgabedatei (Verzeichnisse werden angelegt, vorhandene Datei überschrieben); Standard: `__.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 → __.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 `.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: `.korrigiert.txt` neben der Eingabe, mit `-u` `__.korrigiert.txt` | +| `-o`, `--output` | Ausgabedatei; Standard: `.korrigiert.txt` neben der Eingabe, mit `-u` `__.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 | diff --git a/extract_text.py b/extract_text.py index 8b29f67..662e611 100755 --- a/extract_text.py +++ b/extract_text.py @@ -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: __.txt)", + help="Ausgabedatei (Standard: __.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: - raise SystemExit("Die Untertitel enthalten keinen Text.") + 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") diff --git a/rede_korrigieren.py b/rede_korrigieren.py index 5eaa859..e0086d4 100755 --- a/rede_korrigieren.py +++ b/rede_korrigieren.py @@ -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: .korrigiert.txt, " - "mit -u __.korrigiert.txt)", + "mit -u __.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 .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: - raise SystemExit("Die Eingabedatei ist leer.") + 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 = [] diff --git a/tests/test_extract_text.py b/tests/test_extract_text.py index a993081..5f89306 100644 --- a/tests/test_extract_text.py +++ b/tests/test_extract_text.py @@ -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 diff --git a/tests/test_rede_korrigieren.py b/tests/test_rede_korrigieren.py index b406d95..182c168 100644 --- a/tests/test_rede_korrigieren.py +++ b/tests/test_rede_korrigieren.py @@ -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