Bogo+LinuxコマンドでWordPressブログを自動&多言語翻訳する
Auto multilingual translation for WordPress blogs using Bogo and Linux commands
はじめに
以前より「英語版のBlog記事があればいいな」と思っており、この度ついに作成しました。
以下のURLから確認できます。
- 英語トップ: https://bokumin.org/en/
- 英語ブログ: https://bokumin.org/blog/en/
とはいえ、過去の記事を含めて一つ一つ手動で翻訳して投稿するのはあまりにも面倒です。そこで今回は、サーバーのコマンドラインツールを使ってそれらの作業をすべて自動化しました。
WordPressのプラグイン「Bogo」とGoogle翻訳コマンドを組み合わせた実装方法をまとめましたので、同じようにお金をかけず多言語化したいという方の参考になれば幸いです。
環境
- OS: openSUSE (Kernel 6.18.x)
- Web Server: Apache 2.4.66
- CMS: WordPress
- 必須ツール: wp-cli, trans, perl
※OSはどれでもできると思います。
翻訳エンジンの導入(Google Translate CLI)
サーバー上でコマンドラインから翻訳を行えるツール trans をインストールします。
# 取得+実行権限を付与
wget -O /usr/local/bin/trans git.io/trans
chmod +x /usr/local/bin/trans
# 動作確認(日本語→英語)
trans -b -no-auto :en "テストです"
It's a test
Bogoの導入
多言語化プラグインに「Bogo」を採用しました。Polylangなどと違い、Bogoは日本語記事と英語記事などといった形をそれぞれ独立した投稿として作成するというシンプルな構造になっています。
BogoはWordpressのプラグインからインストールも可能ですし、コマンドでもインストールが可能です。
# Bogoのインストール
wp plugin install bogo --activate --allow-root
自動翻訳スクリプトの作成
今ある記事を翻訳して体裁を整えて・・みたいなのが面倒だったので、以下の処理を自動化するBashスクリプトを作成しました。
- まだ翻訳されていない日本語記事を見つける。
- WordPressのブロック定義(JSON)やコードブロックを保護する
- trans コマンドでタイトルと本文を英語化。
- WP-CLIを使って英語記事を投稿。
- Bogoの機能を使って、日本語記事と英語記事を「翻訳ペア」としてリンクさせる。
ハマった部分
単純に翻訳コマンドを通すだけだと、WordPressのブロックエディタ(Gutenberg)などが持つデータ構造が壊れてしまうことが判明しました。特に以下の2点が厄介でした。
- Code Block Proなどのプラグイン:
コード表示にCode Block Proを使っています。ソースコードを表示するブロックなどは、データがJSON形式で保存されています。翻訳ツールがJSON内の \n(改行)や “(クォート)を勝手に変更してしまうと、ブロック全体が壊れて表示されなくなります。 - バックスラッシュの消失:
シェルスクリプトの変数や echo を経由すると、エスケープ文字(\)が消えてしまうことがあります。これにより、記事投稿時に改行コードなどが消失し、レイアウトが崩れます。
正直なところ、この問題の解決だけで半日ほど時間を取られました・・・
「シェルからPerlに渡す際にエスケープが消え、WordPressに渡す際にもまた消える」という多重消滅トラップにはまったためです。
これらを解決するために、今回のスクリプトではPerlによるマスク処理とWP関数の wp_slash の活用を実装しています。
スクリプトの解説
1.準備
はじめに、WordPressのパスやURL、翻訳ツールの設定を行っていきます。Bogoの _trid(翻訳グループID)を確認し、すでに英語記事が存在する場合はスキップするようにしています
#!/bin/bash
WP_PATH="/srv/www/htdocs/blog" # WordPressを置いているパス
SITE_URL="https://bokumin.org" # 自分のサイトURL
WP_CMD="wp --path=$WP_PATH --url=$SITE_URL --allow-root"
# Google翻訳コマンド (trans) の設定
TRANS_BIN=$(which trans)
# -b: 余計な出力をせず翻訳結果だけ表示
# -no-auto: 自動言語判定をオフにして高速化
TRANS_OPTS="-b -no-auto :en"
SLEEP_TIME=20
2. コンテンツの取得と保護(Perlスクリプト生成)
記事本文を取得する際、シェル変数を経由すると特殊文字が変質するため、PHP経由で直接ファイルに書き出します。
また、翻訳前に重要な部分をマスクするためのPerlスクリプトを動的に生成しました。
これにより、複雑なJSONデータを含んだブロックも安全に翻訳対象外にできます。
$content =~ s{(<!--\s*/?wp:.*?-->)}{ push(@masks, $1); "___MASK_WP_" . $#masks . "___" }gse;
3. 英語記事の作成
翻訳されたテキストをWordPressに登録します。
ここで最も重要になるのが wp_slash() です。WordPressの投稿関数 wp_insert_post は、データがすでにエスケープ(スラッシュ処理)されていることを期待して、保存時に unslash(スラッシュ除去)を行います。
そのため、PHP側で事前に wp_slash() をかけておかないと、記事内のバックスラッシュ(\n や \u003c など)がすべて消えてしまうので、記事が壊れてしまいます。
# WP-CLIの eval コマンドで PHPコードを直接実行
en_id=$($WP_CMD eval "
\$title = base64_decode('$title_b64');
\$content = file_get_contents('$content_file');
// wp_insert_postは保存時にバックスラッシュを取り除くため事前に wp_slash でエスケープしておく必要がある
\$content = wp_slash(\$content);
\$post_data = array(
'post_title' => \$title,
'post_content' => \$content,
// ...
);
\$id = wp_insert_post(\$post_data);
if (!is_wp_error(\$id)) { echo \$id; }
")
作成したスクリプト
※パスやURLは環境に合わせて変更してください
#!/bin/bash
# 設定エリア
WP_PATH="/srv/www/htdocs/blog" # WordPressを置いているパス
SITE_URL="https://bokumin.org" # 自分のサイトURL
# wp & transコマンド
WP_CMD="wp --path=$WP_PATH --url=$SITE_URL --allow-root"
TRANS_BIN=$(which trans)
TRANS_OPTS="-b -no-auto :en"
# スリープ秒数
SLEEP_TIME=20
# Perlスクリプト
PERL_SCRIPT="/tmp/wp_trans_mask.pl"
cat << 'EOF' > "$PERL_SCRIPT"
use strict;
use warnings;
use utf8;
use open ':std', ':encoding(UTF-8)';
my ($t_bin, $t_opts, $t_out) = @ARGV;
# 標準入力から読み込み
my $content = do { local $/; <STDIN> };
my @masks = ();
# 保護処理
# WordPressブロック (<!-- wp:... -->)
$content =~ s{(<!--\s*/?wp:.*?-->)}{ push(@masks, $1); "___MASK_WP_" . $#masks . "___" }gse;
# <script>
$content =~ s{(<script\b[^>]*>.*?</script>)}{ push(@masks, $1); "___MASK_SCRIPT_" . $#masks . "___" }gse;
# <style>
$content =~ s{(<style\b[^>]*>.*?</style>)}{ push(@masks, $1); "___MASK_STYLE_" . $#masks . "___" }gse;
# <pre>
$content =~ s{(<pre\b[^>]*>.*?</pre>)}{ push(@masks, $1); "___MASK_PRE_" . $#masks . "___" }gse;
# <code>
$content =~ s{(<code\b[^>]*>.*?</code>)}{ push(@masks, $1); "___MASK_CODE_" . $#masks . "___" }gse;
# URL
$content =~ s{(https?://[^\s"<]+)}{ push(@masks, $1); "___MASK_URL_" . $#masks . "___" }gse;
# 翻訳実行
open(my $ph, "|-", "$t_bin $t_opts > $t_out") or die "Cannot open pipe to trans";
binmode($ph, ":encoding(UTF-8)");
print $ph $content;
close $ph;
# 復元処理
if (-z "$t_out") { exit; }
open(my $fh, "<:encoding(UTF-8)", "$t_out") or die "Cannot open translated file";
my $trans_content = do { local $/; <$fh> };
close $fh;
# 翻訳機が前後にスペースを入れた場合の対策
$trans_content =~ s/___ ?MASK_WP_ ?(\d+) ?___/$masks[$1]/g;
$trans_content =~ s/___ ?MASK_SCRIPT_ ?(\d+) ?___/$masks[$1]/g;
$trans_content =~ s/___ ?MASK_STYLE_ ?(\d+) ?___/$masks[$1]/g;
$trans_content =~ s/___ ?MASK_PRE_ ?(\d+) ?___/$masks[$1]/g;
$trans_content =~ s/___ ?MASK_CODE_ ?(\d+) ?___/$masks[$1]/g;
$trans_content =~ s/___ ?MASK_URL_ ?(\d+) ?___/$masks[$1]/g;
print $trans_content;
EOF
# メイン処理関数
process_translation() {
local ja_id=$1
echo "========================================"
echo "Checking Post ID: $ja_id..."
# ロケール確認
current_locale=$($WP_CMD post meta get $ja_id _locale)
if [ "$current_locale" == "en_US" ]; then
echo "-> Skip: Already English."
return
fi
# 翻訳済み確認
trid=$($WP_CMD post meta get $ja_id _trid)
if [ -n "$trid" ]; then
existing_en_id=$($WP_CMD post list --post_type=post --format=ids --meta_query="[{\"key\":\"_trid\",\"value\":\"$trid\"},{\"key\":\"_locale\",\"value\":\"en_US\"}]")
if [ -n "$existing_en_id" ]; then
echo "-> Skip: Translation exists (ID: $existing_en_id)."
return
fi
fi
# コンテンツ取得 (PHP経由でファイルへ書き出し)
TEMP_SRC=$(mktemp)
TEMP_TRANS=$(mktemp)
$WP_CMD eval "file_put_contents('$TEMP_SRC', get_post_field('post_content', $ja_id));"
# タイトル取得
ja_title=$($WP_CMD post get $ja_id --field=post_title)
ja_author=$($WP_CMD post get $ja_id --field=post_author)
ja_date=$($WP_CMD post get $ja_id --field=post_date)
# 英語タイトル抽出
extracted_en_title=$(cat "$TEMP_SRC" | sed 's/<[^>]*>//g' | head -n 5 | grep -E "^[A-Za-z0-9 \+\-\.\!\?]+$" | head -n 1)
if [ -n "$extracted_en_title" ]; then
en_title="$extracted_en_title"
else
en_title=$(echo "$ja_title" | $TRANS_BIN $TRANS_OPTS)
sleep 2
fi
# 翻訳実行 (Perlスクリプト)
echo "Translating Content..."
perl "$PERL_SCRIPT" "$TRANS_BIN" "$TRANS_OPTS" "$TEMP_TRANS" < "$TEMP_SRC" > "$TEMP_TRANS.final"
if [ ! -s "$TEMP_TRANS.final" ]; then
echo "[ERROR] Translation failed. Skipping."
rm "$TEMP_SRC" "$TEMP_TRANS" "$TEMP_TRANS.final"
return
fi
# 記事作成 (日付同期 & wp_slash)
echo "Creating English Post..."
title_b64=$(echo -n "$en_title" | base64 | tr -d '\n')
content_file="$TEMP_TRANS.final"
en_id=$($WP_CMD eval "
\$title = base64_decode('$title_b64');
\$date = '$ja_date';
if (file_exists('$content_file')) {
\$content = file_get_contents('$content_file');
// wp_slashでエスケープ
\$content = wp_slash(\$content);
\$post_data = array(
'post_title' => \$title,
'post_content' => \$content,
'post_status' => 'publish',
'post_author' => $ja_author,
'post_date' => \$date, // 日付を指定
'post_type' => 'post'
);
\$id = wp_insert_post(\$post_data);
if (!is_wp_error(\$id)) { echo \$id; } else { echo 0; }
}
")
rm "$TEMP_SRC" "$TEMP_TRANS" "$TEMP_TRANS.final"
if [ -z "$en_id" ] || [ "$en_id" -eq 0 ]; then
echo "[ERROR] Failed to create post."
return
fi
echo "Created Post ID: $en_id (Date: $ja_date)"
# Bogo設定
$WP_CMD post meta update $ja_id _locale ja
$WP_CMD post meta update $en_id _locale en_US
if [ -z "$trid" ]; then
trid=$(date +%s)
$WP_CMD post meta update $ja_id _trid $trid
fi
$WP_CMD post meta update $en_id _trid $trid
# カテゴリ・タグ引き継ぎ
$WP_CMD eval "
\$ja_id = $ja_id;
\$en_id = $en_id;
\$taxonomies = array('category', 'post_tag');
foreach (\$taxonomies as \$tax) {
\$ja_term_ids = wp_get_object_terms(\$ja_id, \$tax, array('fields' => 'ids'));
if ( ! is_wp_error(\$ja_term_ids) && ! empty(\$ja_term_ids) ) {
wp_set_object_terms(\$en_id, \$ja_term_ids, \$tax);
}
}
"
echo "Done. Sleeping $SLEEP_TIME seconds..."
sleep $SLEEP_TIME
}
# ループ処理
ids_ja=$($WP_CMD post list --post_type=post --format=ids --posts_per_page=-1 --meta_key=_locale --meta_value=ja --orderby=date --order=ASC)
ids_none=$($WP_CMD post list --post_type=post --format=ids --posts_per_page=-1 --meta_query='[{"key":"_locale","compare":"NOT EXISTS"}]' --orderby=date --order=ASC)
ids=$(echo "$ids_ja $ids_none" | tr ' ' '\n' | sort -n)
# 最新の1記事のみでいい場合はコメントアウトを外す
#ids=$($WP_CMD post list --post_type=post --format=ids --posts_per_page=1 --meta_key=_locale --meta_value=ja --orderby=date --order=DESC)
if [ -z "$(echo $ids | tr -d ' ')" ]; then
echo "No target posts found."
exit 0
fi
for id in $ids; do
process_translation $id
done
rm -f "$PERL_SCRIPT"
Google翻訳APIの制限(400 Bad Request)を避けるため、スクリプト内では1記事ごとに20秒のスリープを入れています。記事数が多い場合は実行に時間がかかりますが、放置しておけば終わります。
最新の記事だけを取得したい場合は、最後のidsを以下のように修正してもらえれば大丈夫です。
ids=$($WP_CMD post list --post_type=post --format=ids --posts_per_page=1 --meta_key=_locale --meta_value=ja --orderby=date --order=DESC)
for id in $ids; do
process_translation $id
done
おまけ:WordPress外の静的ページを多言語化する方法※興味ない方は飛ばしてください
私のサイト構成はブログ部分(/blog/)はWordPressですが、トップページ(/)やツール置き場(/others/)はWordPressを使わない自作のPHPページで動いています。
BogoはWordPress内しか管理してくれないため、これらのページを連携させる必要があります。
index_en.phpなどを作っても良かったのですが、管理が煩雑になるため却下し「ファイルは1つのまま、URLによって表示言語を切り替える」仕組みを実装しました。
1. サーバー設定 (.htaccess)
まず、物理的なディレクトリを作らずに /en/ というURLでアクセスできるようにするため、Apacheの mod_rewrite を使ってリクエストを転送します。
これによって、/en/ にアクセスが来ても、サーバー内部ではルートの index.php が処理を行うようになります。
RewriteEngine On
RewriteBase /
# トップページ (/en/ -> index.php)
RewriteRule ^en/?$ index.php [L]
# サブディレクトリ (/others/en/ -> others/index.php)
RewriteRule ^others/en/?$ others/index.php [L]
RewriteRule ^others/en/(.*)$ others/$1 [L]
# 特定のファイル (/en/app-list.php -> app-list.php)
RewriteRule ^en/(.*)$ $1 [L]
2. PHP側での言語判定
次に、PHP側(header.php など)で「今のURLは英語かどうか?」を判定するロジックを入れます。
WordPressの関数は使えないため、$_SERVER[‘REQUEST_URI’] を直接チェックします。
<?php
// 現在のURLが '/en' または '/blog/en' で始まっているか判定
$is_english = false;
if (isset($_SERVER['REQUEST_URI']) && preg_match('#^(/blog)?/en(/|$)#', $_SERVER['REQUEST_URI'])) {
$is_english = true;
}
// 内部リンク用のプレフィックスを作成
// 英語なら '/en'、日本語なら '' (空文字)
$link_prefix = $is_english ? '/en' : '';
?>
3. 表示の切り替え(簡易翻訳関数)
日本語と英語を切り替えるために、シンプルなヘルパー関数 _t() を定義しました。これを使うと、HTML内がスッキリします。
<?php
// 簡易翻訳関数
function _t($ja, $en) {
global $is_english;
echo $is_english ? $en : $ja;
}
?>
<!-- 使用例 -->
<h2><?php _t('ようこそ', 'Welcome'); ?></h2>
<p>
<?php _t(
'このサイトは技術ブログです。',
'This site is a technical blog.'
); ?>
</p>
<!-- リンクも自動で英語用に -->
<a href="<?php echo $link_prefix; ?>/others">Others</a></code>
4. サイトマップのハイブリッド生成
最後にSEO対策です。
WordPressの記事は wp-cli で取得できますが、これらの静的ページはDBにないため、シェルスクリプトで「手動リスト」と「WPの自動リスト」を合体させて sitemap.xml を生成するようにしました。
# 静的ページ (今日の日付で更新)
TODAY=$(date +%Y-%m-%d)
echo "https://bokumin.org/,$TODAY" >> sitemap.tmp
echo "https://bokumin.org/en/,$TODAY" >> sitemap.tmp
# WordPress記事 (WP-CLIで取得)
wp eval '...' >> sitemap.tmp
sitemap用のシェルスクリプトの全文はこちらです。
cat make-sitemap.sh
#!/bin/bash
OUTPUT_FILE="/srv/www/htdocs/sitemap.xml"
WEBROOT="/srv/www/htdocs"
DOMAIN="https://bokumin.org"
WP_PATH="/srv/www/htdocs/blog"
WP_CMD="wp --path=$WP_PATH --url=$DOMAIN --allow-root"
cat > "$OUTPUT_FILE" << 'EOF'
<?xml version="1.0" encoding="utf-8" standalone="yes" ?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
EOF
add_url_entry() {
local url_path="$1"
local file_check_path="$2"
local full_path="$WEBROOT$file_check_path"
local lastmod=""
if [[ -f "$full_path" ]]; then
lastmod=$(date -r "$full_path" +%Y-%m-%d)
elif [[ -d "$full_path" ]]; then
lastmod=$(date -r "$full_path" +%Y-%m-%d)
else
return
fi
cat >> "$OUTPUT_FILE" << EOF
<url>
<loc>${DOMAIN}${url_path}</loc>
<lastmod>${lastmod}</lastmod>
</url>
EOF
}
add_url_entry "/" "/index.php"
add_url_entry "/en/" "/index.php"
add_url_entry "/others/" "/others/"
add_url_entry "/others/en/" "/others/"
add_url_entry "/art-works/" "/art-works/"
add_url_entry "/art-works/en/" "/art-works/"
add_url_entry "/gpg-public-key.txt" "/gpg-public-key.txt"
add_url_entry "/spam-check/" "/spam-check/"
add_url_entry "/amedas-dashboard/" "/amedas-dashboard/"
add_url_entry "/blog/" "/blog/"
$WP_CMD eval '
$posts = get_posts(array(
"post_type" => array("post", "page"),
"post_status" => "publish",
"numberposts" => -1,
));
foreach($posts as $post) {
$lastmod = get_the_modified_date("Y-m-d", $post->ID);
$url = get_permalink($post->ID);
echo $url . "\t" . $lastmod . "\n";
}
' | while IFS=$'\t' read -r url lastmod; do
if [ -n "$url" ]; then
cat >> "$OUTPUT_FILE" << EOF
<url>
<loc>$url</loc>
<lastmod>$lastmod</lastmod>
</url>
EOF
fi
done
echo "</urlset>" >> "$OUTPUT_FILE"
chown wwwrun:wwwrun "$OUTPUT_FILE"
まとめ
無料かつ自動でブログの多言語化が完了しました。
技術系の記事はコードブロックが多く翻訳が崩れやすいですが、Perlスクリプトでマスク処理を挟むことで綺麗に翻訳できています。たまにうまく翻訳されていないところがありますが・・まあそこは後で直せば大丈夫です()
Bogoのプラグインの機能で、英語記事・日本語記事の絞り込みで管理もしやすいので、おかしい記事などがあれば手動で修正することも簡単です。

同じような構成を検討している方の参考になれば幸いです。
おわり