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:
parent
1668a8dcad
commit
82489ee83c
5 changed files with 111 additions and 17 deletions
10
README.md
10
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 (`<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 |
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
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 = []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue