feat: extract_text.py – YouTube-URL → Textdatei in einem Schritt

- Kombiniert yt_untertitel.py und vtt2txt.py; die VTT bleibt temporär
- Standardsprache ist die des Original-Audios (original_language())
- -o Ausgabedatei, Standard output.txt
- Tests ohne Netz, README

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-10 19:51:32 +02:00
commit ba61434798
5 changed files with 246 additions and 5 deletions

View file

@ -4,10 +4,11 @@ Korrigiert automatische Abschriften deutscher Reden (z. B. YouTube-Untertitel al
Text) mit einem lokalen Sprachmodell über Ollama: Rechtschreibung, Satzzeichen, Text) mit einem lokalen Sprachmodell über Ollama: Rechtschreibung, Satzzeichen,
Hörfehler, Absätze – möglichst wortgetreu, ohne Zusammenfassung oder Glättung. Hörfehler, Absätze – möglichst wortgetreu, ohne Zusammenfassung oder Glättung.
Drei Werkzeuge, einzeln oder hintereinander nutzbar: Die Werkzeuge sind einzeln oder hintereinander nutzbar:
| Skript | Aufgabe | | Skript | Aufgabe |
|---|---| |---|---|
| `extract_text.py` | YouTube-URL → Textdatei in einem Schritt (die beiden folgenden zusammen) |
| `yt_untertitel.py` | Untertitel eines YouTube-Videos als VTT herunterladen | | `yt_untertitel.py` | Untertitel eines YouTube-Videos als VTT herunterladen |
| `vtt2txt.py` | VTT in eine Rohabschrift (Text) umwandeln | | `vtt2txt.py` | VTT in eine Rohabschrift (Text) umwandeln |
| `rede_korrigieren.py` | Rohabschrift (oder VTT direkt) mit dem Sprachmodell korrigieren | | `rede_korrigieren.py` | Rohabschrift (oder VTT direkt) mit dem Sprachmodell korrigieren |
@ -26,7 +27,14 @@ der Lauf wird etwa 12-mal langsamer.
## Aufruf ## Aufruf
Ablauf von der YouTube-URL zum korrigierten Text: Von der YouTube-URL zum korrigierten Text:
```bash
./extract_text.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -o lahnstein.txt
./rede_korrigieren.py -i lahnstein.txt # → lahnstein.korrigiert.txt
```
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 testdaten ./yt_untertitel.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -n Lahnstein -d testdaten
@ -34,9 +42,27 @@ Ablauf von der YouTube-URL zum korrigierten Text:
./rede_korrigieren.py -i testdaten/Lahnstein.de-orig.txt ./rede_korrigieren.py -i testdaten/Lahnstein.de-orig.txt
``` ```
Der mittlere Schritt ist optional: `rede_korrigieren.py -i testdaten/Lahnstein.de-orig.vtt` Der Schritt über `vtt2txt.py` ist optional: `rede_korrigieren.py` nimmt auch die
wandelt die Untertitel selbst um. Die `.txt` lohnt sich, um den Rohtext anzusehen `.vtt` direkt. Die `.txt` lohnt sich, um den Rohtext anzusehen oder ein Glossar
oder ein Glossar daraus zu erstellen. daraus zu erstellen.
### URL → Text in einem Schritt: `extract_text.py`
Lädt die Untertitel und speichert nur den Text; die VTT-Datei bleibt ein
Zwischenschritt im temporären Verzeichnis. Die Spurwahl ist dieselbe wie bei
`yt_untertitel.py`, nur ist die Standardsprache die des Original-Audios.
```bash
./extract_text.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' # → output.txt
./extract_text.py -u URL -l de -o testdaten/rede.txt # Sprache und Ziel
./rede_korrigieren.py -i output.txt # → output.korrigiert.txt
```
| Option | Bedeutung |
|---|---|
| `-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: `output.txt` |
### Untertitel herunterladen: `yt_untertitel.py` ### Untertitel herunterladen: `yt_untertitel.py`
@ -165,6 +191,8 @@ Die Tests brauchen kein Ollama: Modellaufrufe werden durch Fakes ersetzt. Geprü
werden Abschnittsbildung, Glossar (inkl. „jede Ersetzung in den Beispiel-Glossaren werden Abschnittsbildung, Glossar (inkl. „jede Ersetzung in den Beispiel-Glossaren
trifft ihre Testdaten“), Streaming-Auswertung, Absatzgliederung (Text bleibt trifft ihre Testdaten“), Streaming-Auswertung, Absatzgliederung (Text bleibt
unverändert, 3–10 Sätze), Zwischenspeicher/Fortsetzen und die Kommandozeile. unverändert, 3–10 Sätze), Zwischenspeicher/Fortsetzen und die Kommandozeile.
`tests/test_extract_text.py` prüft den Gesamtablauf URL → Text, Sprachwahl
(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_vtt2txt.py` prüft die VTT-Umwandlung: jede `.vtt` in `testdaten/` `tests/test_vtt2txt.py` prüft die VTT-Umwandlung: jede `.vtt` in `testdaten/`

77
extract_text.py Executable file
View file

@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Lädt die Untertitel eines YouTube-Videos und speichert ihren Text als Datei
(yt_untertitel.py + vtt2txt.py in einem Schritt)."""
from pathlib import Path
import argparse
import shutil
import tempfile
from vtt2txt import vtt_to_lines
from yt_untertitel import (
YT_DLP, available_tracks, choose_track, download, fetch_info, original_language,
)
def parse_args():
parser = argparse.ArgumentParser(
description="Lädt die Untertitel eines YouTube-Videos und speichert den Text."
)
parser.add_argument("-u", "--url", required=True, help="YouTube-URL des Videos")
parser.add_argument(
"-l", "--lang",
help="Sprache der Untertitel, z. B. de (Standard: Sprache des Original-Audios)",
)
parser.add_argument(
"-o", "--output", type=Path, default=Path("output.txt"),
help="Ausgabedatei (Standard: output.txt)",
)
args = parser.parse_args()
if args.output.is_dir():
parser.error(f"-o/--output ist ein Verzeichnis: {args.output}")
if shutil.which(YT_DLP) is None:
parser.error(f"{YT_DLP} nicht gefunden (Installation: pip install yt-dlp).")
return args
def main():
args = parse_args()
info = fetch_info(args.url)
print(f"Video: {info.get('title', '?')}")
lang = args.lang or original_language(info)
chosen = choose_track(info, lang) if lang else None
if chosen is None:
manual, original = available_tracks(info)
wanted = f"„{lang}“" if lang else "der Originalsprache"
raise SystemExit(
f"Keine Untertitel in {wanted} (weder manuell noch automatisch in "
f"Originalsprache).\n"
f"Manuell: {', '.join(manual) or '–'}\n"
f"Automatisch (Originalsprache): {', '.join(original) or '–'}"
)
track, is_auto = chosen
kind = "automatische" if is_auto else "manuelle"
print(f"Lade {kind} Untertitel „{track}“ …", flush=True)
# The VTT file is only an intermediate step
with tempfile.TemporaryDirectory() as tmp:
target_base = Path(tmp) / "untertitel"
download(args.url, track, is_auto, target_base)
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"))
if not lines:
raise SystemExit("Die Untertitel enthalten keinen Text.")
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text("\n".join(lines) + "\n", encoding="utf-8")
words = sum(len(line.split()) for line in lines)
print(f"Gespeichert: {args.output} ({len(lines)} Zeilen, {words} Wörter)")
if __name__ == "__main__":
main()

116
tests/test_extract_text.py Normal file
View file

@ -0,0 +1,116 @@
"""Tests for extract_text.py; yt-dlp is always replaced by fakes (no network)."""
import json
import subprocess
import sys
from pathlib import Path
import pytest
import extract_text
import yt_untertitel as yt
URL = "https://www.youtube.com/watch?v=abc123"
VTT = (
"WEBVTT\n\n"
"00:00:01.000 --> 00:00:02.000\n \nGuten<00:00:01.500><c> Abend.</c>\n\n"
"00:00:02.000 --> 00:00:03.000\nGuten Abend.\nSchön, dass Sie da sind.\n"
)
GERMAN_VIDEO = {
"id": "abc123",
"title": "Eine Rede",
"subtitles": {"en": []},
"automatic_captions": {"de-orig": [], "de": [], "en": []},
}
def fake_yt_dlp(info, vtt=VTT):
"""Stand-in for subprocess.run: answers the info request, writes the VTT file."""
calls = []
def run(command, capture_output, text):
calls.append(command)
if "--dump-single-json" in command:
return subprocess.CompletedProcess(command, 0, json.dumps(info), "")
track = command[command.index("--sub-langs") + 1]
template = command[command.index("-o") + 1]
Path(template.replace("%(ext)s", f"{track}.vtt")).write_text(vtt, encoding="utf-8")
return subprocess.CompletedProcess(command, 0, "", "")
run.calls = calls
return run
@pytest.fixture(autouse=True)
def yt_dlp_installed(monkeypatch):
monkeypatch.setattr(extract_text.shutil, "which", lambda name: f"/usr/bin/{name}")
def run_main(monkeypatch, *args):
monkeypatch.setattr(sys, "argv", ["extract_text.py", *map(str, args)])
extract_text.main()
def test_default_language_is_original_audio(tmp_path, monkeypatch, capsys):
run = fake_yt_dlp(GERMAN_VIDEO)
monkeypatch.setattr(yt.subprocess, "run", run)
monkeypatch.chdir(tmp_path)
run_main(monkeypatch, "-u", URL)
text = (tmp_path / "output.txt").read_text(encoding="utf-8")
assert text == "Guten Abend.\nSchön, dass Sie da sind.\n"
assert "de-orig" in run.calls[1]
assert "--write-auto-subs" in run.calls[1]
out = capsys.readouterr().out
assert "output.txt (2 Zeilen, 7 Wörter)" in out
# Only the text file remains, no VTT
assert [p.name for p in tmp_path.iterdir()] == ["output.txt"]
def test_explicit_language_prefers_manual(tmp_path, monkeypatch):
run = fake_yt_dlp(GERMAN_VIDEO)
monkeypatch.setattr(yt.subprocess, "run", run)
target = tmp_path / "neu" / "rede.en.txt"
run_main(monkeypatch, "-u", URL, "-l", "en", "-o", target)
assert target.is_file()
assert "--write-subs" in run.calls[1]
def test_original_language_from_metadata_without_auto_captions(tmp_path, monkeypatch):
info = {"id": "x", "language": "fr", "subtitles": {"fr": []}, "automatic_captions": {}}
run = fake_yt_dlp(info)
monkeypatch.setattr(yt.subprocess, "run", run)
run_main(monkeypatch, "-u", URL, "-o", tmp_path / "out.txt")
assert run.calls[1][run.calls[1].index("--sub-langs") + 1] == "fr"
@pytest.mark.parametrize("info, args, message", [
# Translation only: no "en-orig", no manual "en"
(GERMAN_VIDEO, ["-l", "fr"], "Keine Untertitel in „fr“"),
({"id": "x", "subtitles": {}, "automatic_captions": {}}, [], "Originalsprache"),
])
def test_no_matching_track(tmp_path, monkeypatch, info, args, message):
monkeypatch.setattr(yt.subprocess, "run", fake_yt_dlp(info))
with pytest.raises(SystemExit, match=message):
run_main(monkeypatch, "-u", URL, "-o", tmp_path / "out.txt", *args)
assert not (tmp_path / "out.txt").exists()
def test_empty_subtitles(tmp_path, monkeypatch):
monkeypatch.setattr(yt.subprocess, "run", fake_yt_dlp(GERMAN_VIDEO, vtt="WEBVTT\n"))
with pytest.raises(SystemExit, match="keinen Text"):
run_main(monkeypatch, "-u", URL, "-o", tmp_path / "out.txt")
@pytest.mark.parametrize("args, message", [
([], "required"),
(["-u", URL, "-o", "{dir}"], "ist ein Verzeichnis"),
])
def test_cli_errors(tmp_path, monkeypatch, capsys, args, message):
args = [a.format(dir=tmp_path) 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

View file

@ -63,6 +63,15 @@ def test_choose_track(info, lang, expected):
assert yt.choose_track(info, lang) == expected assert yt.choose_track(info, lang) == expected
@pytest.mark.parametrize("info, expected", [
(AUTO_ONLY, "de"),
({"language": "fr", "automatic_captions": {}}, "fr"),
({"subtitles": {"en": []}}, None),
])
def test_original_language(info, expected):
assert yt.original_language(info) == expected
def test_available_tracks(): def test_available_tracks():
info = {"subtitles": {"fr": []}, "automatic_captions": {"de-orig": [], "de": []}} info = {"subtitles": {"fr": []}, "automatic_captions": {"de-orig": [], "de": []}}
assert yt.available_tracks(info) == (["fr"], ["de"]) assert yt.available_tracks(info) == (["fr"], ["de"])

View file

@ -35,6 +35,17 @@ def choose_track(info, lang):
return None return None
def original_language(info):
"""Language of the video's audio, or None if yt-dlp does not know it.
YouTube marks the automatic captions in the spoken language with "-orig".
"""
for name in info.get("automatic_captions") or {}:
if name.endswith("-orig"):
return name.removesuffix("-orig")
return info.get("language")
def available_tracks(info): def available_tracks(info):
manual = sorted((info.get("subtitles") or {}).keys()) manual = sorted((info.get("subtitles") or {}).keys())
original = sorted( original = sorted(