RustでEPUBをOpenAI APIで翻訳するCLIを作ってみた
背景
The Rust Programming Language 日本語版 を読んだあと、英語できないけどRustの英語の本を読みたいなと思っていたところepub-translatorに出会い、インスパイアされました
私の好きなKotlinで実装されており、DeepL APIで翻訳してくれるOSSです
DeepLは契約していないのと「これもしかしたらRustの勉強の材料に良いんじゃないか」と思ったので実装してみました
将来的にはOSや電子書籍リーダー、OpenAI側で翻訳してくれると思うので、あくまでも勉強の一貫としてやっていこうと思ってます
翻訳されている本がでているのであれば、そちらを購入することを強くおすすめします
あくまでも翻訳されていない本を読む補助のツールです
APIを並列でコールしているものの、Ratelimitがあるため翻訳に時間掛かります
また、20行単位で翻訳しているのですが、原文と訳文で行数が合わない場合は1行ずつ再度APIをコールしているため単純にテキストを翻訳するよりも課金されています
後述の通り、ルビを除去したりしていますし、文中の画像やソースコード内のコメントは翻訳していません
利用方法
インストール
curl -OL https://github.com/tomiyan/trans-epub/releases/download/0.0.9/trans-epub-0.0.9-macos-arm64.tar.gz
tar xvzf trans-epub-0.0.9-macos-arm64.tar.gz
実行
export API_KEY=sk-....
./trans-epub open-ai -i ./origin.epub -o ./translated.epub -l Japanese
どう開発をすすめていったか
環境構築
The Rust Programming LanguageでRustに触れていたのでそちらを参考にしました
IDEはRustRoverがちょうどでたので使ってみました
CI/CD
cargo-generateとRust GitHub Template を利用して構築しました
CLIの実装
clap がメジャーということなので、EPUBの入力、出力ファイルパスを受け取るところから実装はじめました
EPUBの構造の理解とパースの実装
EPUBフォーマット構造の理解 を参考に、手元にあるEPUBや青空文庫のEPUBの中身を実際にみました
実態はZIPで圧縮されたXML、XHTML、CSSが大半でした(ZIPで圧縮されていないものも中にはありました)
EPUBのライブラリも存在するものの、リードとライトでライブラリが分かれているものが多く、両方同じようにできたほうが良いので、XMLのリーダー、ライター quick-xmlを利用しました
日本語のドキュメントはルビが振られており、ルビを一旦取り除かないと翻訳したい文章とならないため除去しました
(翻訳したあとの文章にルビを戻す方法も思いつかない)
<ruby>明日<rt>あした</rt></rp></ruby>は
<ruby>明日<rt>あした</rt></ruby>の<ruby>風<rt>かぜ</rt></ruby>がふく
<code></code>
や<pre></pre>
など翻訳しない方が良いタグも存在します
最初は翻訳しないタグをリストアップしていったのですが、出版社ごとにHTMLの構造が違うため翻訳するタグのみリストアップした方が手っ取り早そうだったため<h1></h1>
や<p></p>
などを翻訳することにしました
OpenAIのAPIの実装
API referenceを読みつつCURLで叩いてみて間違えないことを確認したあと、HTTPクライアントはreqwest、JSONのシリアライズ、デシリアライズはserde、非同期ランタイムはtokioを利用しました
Rate limits
Rate limits で制約があります
レスポンスヘッダーに x-ratelimit-reset-requests
や x-ratelimit-reset-tokens
があるので回復するまでsleepする処理を入れました
原文と訳文との行数の不一致
なるべくAPIのコール回数を減らすために20行単位で翻訳しています
下記みたいな形でSystem message、User messageを入力しています
User messageのcontentはArrayで1行ごとに入力しています
あなたは優れた翻訳者です
日本語に翻訳してください
以下のJSONを出力してください
`<paragraph>`タグから`</paragraph>`タグの文字列が1段落です
results` Keyの値は配列型です
入力された段落ごとに1行ずつ出力してください
入力された段落が20行あるので、20行出力してください
入力された段落が翻訳され、1つの段落が複数の文で構成されている場合は、複数のStringからなる配列を出力してください
翻訳結果から `<paragraph>` タグと `</paragraph>` タグを削除してください
<paragraph>翻訳したい文章</paragraph>
ただ、OpenAIの気まぐれで原文と訳文で行数が合わないことが、10回に1回くらいの割合で発生します
複数の行を1行として翻訳してしまうことがあります
その場合、1行単位でAPIをコールして回避しています
今後
Rustで所有権あたりはChatGPTに聞きながらデバッグしたので、本読んで理解しながら改善していきたいと思っています(きれいなモジュールの分け方やちゃんとしたエラーハンドリングも)
また、機能的にはGemini、Claudeに対応したいなと思っています
Discussion