Das Problem und die Idee
Ich möchte einen Blog-Beitrag auf Englisch oder Deutsch schreiben, und ich möchte ihn nicht jedes Mal jedes Mal manuell übersetzen. Das ist viel einfacher zu pflegen und natürlich viel weniger lästig.
Die Idee ist also, diesen Übersetzungsprozess zu automatisieren. Es soll also automatisch erkannt werden, ob der Beitrag in Deutsch oder Englisch verfasst ist und in die jeweils andere Sprache.
Ich möchte Github Actions verwenden, um vor der Bereitstellung zu übersetzen. Hierfür möchte ich einen kostenlosen Übersetzungsdienst nutzen.
Die Übersetzungsmöglichkeiten
Es gibt viele Möglichkeiten, Text mit Code zu übersetzen. Die meisten von ihnen sind nicht kostenlos oder haben eine begrenzte Länge an Zeichen. Zum Beispiel die API-Schnittstelle von bekannten Übersetzungsdiensten wie DeepL API, oder KI-Dienste wie OpenAI (die einen kostenpflichtigen API-Schlüssel erfordern).
Googletrans
Für den ersten Start möchte ich mit einer kostenlosen Version arbeiten. Zu diesem Zweck habe ich das freie Python-Paket googletrans. Es ist eigentlich veraltet, aber es funktioniert noch mit Python 3.12. Das ist in meinem Fall in Ordnung, denn ich kann es in Github Actions unter Verwendung einer alten Umgebung ausführen kann.
Hugo mehrsprachiger Modus
Um eine mehrsprachige Website zu realisieren, muss Hugo im mehrsprachigen
Modus vorbereitet werden.
Die Haupteinstellung wird in der hugo.toml
vorgenommen.
In meinem Fall funktioniert es am besten wie folgt:
title = 'Website title'
theme = 'PaperMod'
defaultContentLanguage = "de"
defaultContentLanguageInSubdir = true
enableMissingTranslationPlaceholders = true
[languages]
[languages.de]
baseURL = 'https://<githubUsername>.github.io/<reponame>/de'
languageName = "Deutsch"
weight = 1
contentDir = "content"
[languages.en]
baseURL = 'https://<githubUsername>.github.io/<reponame>/en'
languageName = "English"
weight = 2
contentDir = "content"
Die Markdown-Dateien werden wie folgt erzeugt:
content
- post1
-- index.md (original file)
-- index.en.md
-- index.de.md
- post2
-- index.md (original file)
-- index.en.md
-- index.de.md
So wird übersetzt
Realisierung der automatischen Übersetzung mit Github Actions und Python
Einrichtung der Übersetzungsumgebung im Github Actions Workflow
Der erste Teil der Arbeit mit Github Actions besteht darin, eine Python 3.12-Umgebung einzurichten und die Installation der erforderlichen Abhängigkeiten. Der zweite Teil besteht darin, das eigentliche Übersetzungsskript in Python auszuführen und die Übergabe der neuen, generierten Markdown-Dateien.
Der zweite Job ist das Deployment in das öffentliche Verzeichnis (in dem sich die hugo html-Dateien) in den gh-pages-Zweig, der in Github Pages verwendet werden kann.
jobs:
translate:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.12'
- name: Install dependencies
run: pip install requests python-frontmatter langdetect googletrans==4.0.0-rc1
- name: Translate Markdown files
run: python scripts/translate_markdown.py
- name: Commit translated files
run: |
git config user.name "github-actions"
git config user.email "github-actions@users.noreply.github.com"
git add content/
git diff --cached --quiet || git commit -m "Auto-translated markdown files"
git push
continue-on-error: true
deploy:
runs-on: ubuntu-22.04
needs: translate
env:
HUGO_CACHEDIR: /tmp/hugo_cache
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
steps:
- uses: actions/checkout@v4
with:
submodules: true # Fetch Hugo themes (true OR recursive)
fetch-depth: 0 # Fetch all history for .GitInfo and .Lastmod
- name: Setup Hugo
uses: peaceiris/actions-hugo@v3
with:
hugo-version: 'latest'
extended: true
- name: Define cache
uses: actions/cache@v4
with:
path: ${{ env.HUGO_CACHEDIR }}
key: ${{ runner.os }}-hugomod-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-hugomod-
- name: Build
run: hugo --minify
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
if: github.ref == 'refs/heads/main'
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./public
Automatische Übersetzung
Das Übersetzungsskript besteht aus den folgenden Hauptbestandteilen:
- Finden von Markdown-Dateien
- Prüfen, ob Dateien geändert wurden (Hash-Check)
- Identifizierung der Sprache der Markdown-Datei
- Ersetzen von Codeblöcken, Shortcodes, Urls, … durch Platzhalter (Maskierung)
- Übersetzen
- Demaskieren von Platzhaltern
- Übersetzten Text in neuen Dateien speichern
import frontmatter
import hashlib
import re
from langdetect import detect
from pathlib import Path
import shutil
from googletrans import Translator
LANGS = {"de", "en"}
BASE_PATH = Path("content")
translator = Translator()
def hash_text(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def translate(text: str, source: str, target: str) -> str:
try:
result = translator.translate(text, src=source, dest=target)
return result.text
except Exception as e:
print(f"Translation failed: {e}")
return ""
def mask_placeholders(text):
placeholders = {}
placeholder_id = 0
def add_placeholder(match):
nonlocal placeholder_id
ph = f"[[000001100000{placeholder_id}]]"
placeholders[ph] = match.group(0)
placeholder_id += 1
return ph
# Mask code blocks
text = re.sub(r"```.*?```", add_placeholder, text, flags=re.DOTALL)
# Mask inline code
text = re.sub(r"`[^`]+`", add_placeholder, text)
# Mask full markdown links and images
text = re.sub(r"!\[[^\]]*\]\([^)]+\)", add_placeholder, text) # images
text = re.sub(r"\[[^\]]+\]\([^)]+\)", add_placeholder, text) # links
# Mask Hugo shortcodes
text = re.sub(r"\{\{\s*[<%].*?[>%]\s*\}\}", add_placeholder, text, flags=re.DOTALL)
# Mask **_**
text = re.sub(r"\*\*(.*?)\*\*", add_placeholder, text, flags=re.DOTALL)
return text, placeholders
def unmask_placeholders(text, placeholders):
for ph, original in placeholders.items():
text = text.replace(ph, original)
return text
for md_file in BASE_PATH.rglob("*.md"):
if md_file.suffix != ".md":
continue
elif md_file.name.endswith((".de.md", ".en.md")):
continue # skip already translated files
else:
print(f"file: {md_file}")
# Load base file
post = frontmatter.load(md_file)
content = post.content
try:
detected_lang = detect(content)
except Exception as e:
print(f"Could not detect language for {md_file}: {e}")
continue
if detected_lang not in LANGS:
print(f"Skipping {md_file}: unsupported language ({detected_lang})")
continue
other_lang = "en" if detected_lang == "de" else "de"
base_name = md_file.stem
parent_dir = md_file.parent
source_file = parent_dir / f"{base_name}.{detected_lang}.md"
target_file = parent_dir / f"{base_name}.{other_lang}.md"
post_hash = hash_text(content)
# Skip if translated file exists AND content hash hasn't changed
if target_file.exists():
existing_translated = frontmatter.load(target_file)
if existing_translated.get("base_hash") == post_hash:
print(f"✅ Skipping {md_file}: translation up-to-date")
continue
else:
print(f"🔁 Updating translation for {md_file} → {target_file}")
else:
print(f"🌍 Translating {md_file} → {target_file}")
# Copy base file to language-specific name if missing
shutil.copy(md_file, source_file)
print(f"📄 Copied {md_file} → {source_file}")
# Mask placeholders before translation
masked_content, placeholders = mask_placeholders(content)
# Translate masked content
translated_masked_content = translate(masked_content, detected_lang, other_lang)
if not translated_masked_content:
print(
f"⚠️ Translation failed for {md_file} from {detected_lang} to {other_lang}, skipping."
)
continue
# Unmask placeholders in translated content
translated_content = unmask_placeholders(translated_masked_content, placeholders)
# Prepare translated post with metadata and base_hash
translated_post = frontmatter.Post(translated_content, **post.metadata)
translated_post["base_hash"] = post_hash
# Translate title if exists
if "title" in post.metadata:
translated_title = translate(post.metadata["title"], detected_lang, other_lang)
if translated_title:
translated_post["title"] = translated_title
# Save translated file
with open(target_file, "w", encoding="utf-8") as f:
f.write(frontmatter.dumps(translated_post))
print(f"✅ Translated and saved {target_file}")
Nachdem die neuen Dateien commited wurden. Der Push nach Main löst einen neuen Hugo-Build aus.