Devlog #20 — Automatyzacja potoku i18n EN→UK: Python, tłumaczenie maszynowe i równoległe drzewa

Ręczne tłumaczenie ponad 200 stron symulacji na ukraiński nigdy nie miało szans się skalować. Ten wpis opisuje potok automatyzacji, który zbudowaliśmy: porównywanie struktury HTML, tłumaczenie maszynowe na poziomie segmentów, proces post-edycji, wstrzykiwanie hreflang oraz równoległe drzewo katalogów /uk/, które odzwierciedla każdą angielską stronę symulacji.

Problem: dwa równoległe drzewa treści

mysimulator.uk obsługuje dwa pełne drzewa treści: angielskie (korzeń) i ukraińskie (/uk/). Każda symulacja ma odpowiadającą jej stronę ukraińską. Gdy dodajemy nową symulację lub aktualizujemy treść po stronie angielskiej, wersja ukraińska musi pozostać zsynchronizowana — inaczej strona staje się niespójna dla ukraińskich użytkowników.

Początkowo był to proces ręczny. Działał dla pierwszych 30 symulacji. Przy symulacji nr 80 stał się wąskim gardłem. Przy 150 — był już nie do utrzymania. Rozwiązaniem był potok w Pythonie, który wykrywa zmiany w angielskim HTML, wyodrębnia węzły tekstowe do przetłumaczenia, tłumaczy je i zapisuje z powrotem ustrukturyzowany ukraiński HTML.

Przegląd potoku

1
Wykrywanie różnic

Porównanie angielskiego index.html z istniejącym /uk/index.html poprzez haszowanie treści tekstowej każdego elementu podlegającego tłumaczeniu. Do kolejki tłumaczenia trafiają tylko zmienione segmenty — niezmieniony tekst pozostaje bez zmian, aby uniknąć ponownego tłumaczenia tekstu poprawionego przez człowieka.

2
Parsowanie HTML i ekstrakcja tekstu

BeautifulSoup 4 parsuje źródłowy HTML. Węzły tekstowe wewnątrz h1, h2, h3, p, li, meta[name=description], title oraz atrybutów alt są wyodrębniane jako płaska lista segmentów. Struktura jest zachowywana za pomocą identyfikatorów slotów przypominających XPath.

3
Tłumaczenie maszynowe

Segmenty są grupowane i wysyłane do API tłumaczenia w partiach po 50. Terminy techniczne — nazwy symulacji, nazwy algorytmów, nazwy własne, jednostki — są chronione za pomocą glosariusza i placeholderów w stylu XML, dzięki czemu przechodzą bez tłumaczenia.

4
Kolejka przeglądu post-edycji

Przetłumaczone segmenty z wynikiem pewności poniżej 0.85 są zapisywane do pliku review_queue.json. Recenzent może otworzyć ten plik, poprawić wpisy i ponownie uruchomić potok. Sprawdzone segmenty są buforowane w translation_memory.json, więc nigdy nie są tłumaczone ponownie.

5
Rekonstrukcja HTML

Przetłumaczone segmenty są wstawiane z powrotem do struktury HTML z użyciem identyfikatorów slotów z kroku 2. Atrybuty URL (href, src, action) są przepisywane, aby poprawnie rozwiązywać ścieżki względne pod /uk/slug/index.html.

6
Wstrzykiwanie hreflang i canonical

Skrypt wstawia poprawne tagi <link rel="canonical"> oraz <link rel="alternate" hreflang> zarówno dla wersji angielskiej, jak i ukraińskiej, wskazując wzajemnie na siebie.

# translate_sims.py — uproszczona główna pętla
from bs4 import BeautifulSoup
import json, hashlib, pathlib

TRANS_TAGS = {'h1', 'h2', 'h3', 'p', 'li', 'title'}

def extract_segments(html_path: pathlib.Path) -> list[dict]:
    soup = BeautifulSoup(html_path.read_text('utf-8'), 'html.parser')
    segments = []
    for i, tag in enumerate(soup.find_all(TRANS_TAGS)):
        text = tag.get_text(strip=True)
        if len(text) > 3:
            segments.append({
                'id': f'{tag.name}_{i}',
                'text': text,
                'hash': hashlib.sha1(text.encode()).hexdigest()[:8],
            })
    return segments

def apply_translations(source_html: str, translations: dict) -> str:
    soup = BeautifulSoup(source_html, 'html.parser')
    for i, tag in enumerate(soup.find_all(TRANS_TAGS)):
        key = f'{tag.name}_{i}'
        if key in translations:
            tag.clear()
            tag.append(translations[key])
    return str(soup)

Obsługa terminów technicznych: ochrona glosariusza

Terminy fizyczne i matematyczne nie tłumaczą się dobrze — „Navier-Stokes”, „Runge-Kutta”, „leaky integrate-and-fire” — wszystkie muszą przejść bez zmian. Nasze podejście z glosariuszem owija chronione terminy w tagi placeholderów w stylu XML przed tłumaczeniem:

# Wejście:  "The Runge-Kutta RK4 integrator solves stiff ODEs."
# Po ochronie:
#   "The <P1/> integrator solves stiff ODEs."
#   protected_map = {'P1': 'Runge-Kutta RK4'}
# Po MT:
#   "Integrator <P1/> rozwiązuje sztywne równania różniczkowe zwyczajne."
# Po przywróceniu:
#   "Integrator Runge-Kutta RK4 rozwiązuje sztywne równania różniczkowe zwyczajne."

Sprawdzanie synchronizacji w CI

Proces CI uruchamia się przy każdym pushu do main. Porównuje manifest haszy stron angielskich z manifestem ukraińskim i zgłasza wszystkie strony, które wypadły z synchronizacji:

# CI workflow: i18n-sync-check (fragment)
jobs:
  sync-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Check EN/UK sync
        run: python _check_sync.py --report --fail-on-drift

Wnioski: najtrudniejszą częścią nie było samo tłumaczenie — było nią wierne zachowanie struktury HTML. BeautifulSoup podczas serializacji drzewa czasami zwija samo-zamykające się tagi i niepostrzeżenie zmienia kolejność atrybutów. Przeszliśmy na strategię rekonstrukcji opartej na slotach (zamiast mutacji wewnątrz drzewa), aby uzyskać bajtowo-stabilny wynik.