/** * 特集(FEATURE_MT)の本文系カラムを自動翻訳し、日本語と同期させる。 * * 背景(2026-08-27 調査) * ---------------------- * 特集の EN / ZH / 繁体字ページで、本文・FAQ が日本語版と内容が一致していなかった。 * 実測(www.tower.ne.jp・公開55特集)で **19特集・52組(フィールド×言語)** が * 「訳はあるが日本語より短い旧版のまま」だった。例: * - 大阪市北区 … 日本語は4セクション(特徴/市場/住環境/向く人)、EN・ZH は1セクション * - バランス型投資 … 日本語3セクション、EN 2・ZH 1(5つのスコア軸が丸ごと欠落) * - 淀屋橋・心斎橋等 … FAQ が日本語3問に対し EN/ZH 1〜2問 * * 原因は**翻訳の欠損チェックが「空かどうか」しか見ていなかった**こと。 * 運用も `scripts/feature_export.php` → 外部で翻訳 → `scripts/feature_import.php` の * **手作業**で、日本語を書き足しても誰かが思い出さない限り訳は古いまま残った。 * `EP_Room_Description_I18n`(住戸コメント)と同じくプラグイン内に生成を持たせ、 * さらに**翻訳元のハッシュ**(`FEATURE_I18N_HASH`)を控えて「古い訳」を検知する。 * * 方針 * ---- * - **繁体字カラムは作らない。** 表示側 `EP_Feature_Service::get_feature_body()` が * 簡体字を `EP_Zh_Hant::convert()` して返す(他の特集カラムと同じ扱い)。 * - 日本語が空のフィールドは訳も消す(訳だけ残ると消したはずの内容が出続ける)。 * - **ハッシュが無い既存の訳は「形」で判断する** — 日本語とブロック要素数・FAQ件数が * 合っていれば尊重してハッシュだけ控え、違えば作り直す。全件を機械翻訳で * 塗り潰さないための妥協点。 * - **FAQ は JSON をモデルに組み立てさせない。** 日本語側を `json_decode()` して * q / a を配列で渡し、返ってきた訳で**こちら側が組み直す**。件数が合わない応答は捨てる * (壊れた JSON が入ると FAQPage の構造化データごと落ちる)。 * - 訳にかなが混じっていたら「訳せていない」とみなして捨てる(日本語の混在を防ぐ)。 * - ⚠️ **捨てた訳のハッシュは控えない。** 控えると「古い訳+新しいハッシュ」になり * 二度と再翻訳されなくなる。 * - 失敗しても呼び出し元は壊さない。次回実行で再試行される。 * * 使い方 * ------ * wp ep-feature translate --dry-run # 何が古いかだけ出す(APIを叩かない) * wp ep-feature translate # 足りない・古いものだけ翻訳 * wp ep-feature translate --id=52 --force * * ⚠️ **本番2台で別々に走らせると訳文が食い違う**(生成は非決定的)。片方で走らせて * `FEATURE_*_EN/_ZH` と `FEATURE_I18N_HASH` をもう1台へコピーすること。 */ if (!defined('ABSPATH')) { exit; } class EP_Feature_I18n { private const OPENAI_API_URL = 'https://api.openai.com/v1/chat/completions'; /** * 翻訳に使う既定モデル。 * ⚠️ `chatbot_model` を流用しないこと(本番は gpt-5.6-luna で定型文の翻訳には過剰)。 * 変更は `GLOBAL_VAL` の `translation_model` で行う(住戸コメント翻訳と共通)。 */ private const DEFAULT_MODEL = 'gpt-5.4-mini'; /** 対象の基準カラム => 種別(html / text / faq) */ private const FIELDS = [ 'FEATURE_ARTICLE' => 'html', 'FEATURE_FAQ' => 'faq', 'FEATURE_SUMMARY' => 'text', 'FEATURE_DESCRIPTION' => 'text', 'FEATURE_SHORT_TITLE' => 'text', 'FEATURE_TITLE' => 'text', ]; /** カラムサフィックス => プロンプトで使う言語名 */ private const LANGS = [ 'EN' => 'English', 'ZH' => 'Simplified Chinese', ]; private const MAX_SOURCE_LENGTH = 12000; /** * 翻訳元(日本語)のハッシュを控える列。JSON で `{"FEATURE_ARTICLE_EN":"", ...}`。 * * ⚠️ **これが無いと「訳はあるが古い」を検知できない。** * 2026-08-27 実測で、公開55特集のうち **52組(特集×言語)の本文・FAQ が * 日本語より短い旧版のまま**だった(例: 大阪市北区は日本語4セクションに対し * EN/ZH は1セクション、バランス型投資は ZH が1/3)。訳が「空でない」ため * 欠損チェックを素通りしており、日本語を書き足した時点で静かにズレていた。 */ private const HASH_COLUMN = 'FEATURE_I18N_HASH'; public static function init(): void { if (defined('WP_CLI') && WP_CLI) { WP_CLI::add_command('ep-feature translate', [__CLASS__, 'cli_translate']); } } /** * ハッシュ列を用意する。冪等なので毎回呼んでよい。 */ public static function ensure_columns(): void { global $wpdb; static $done = false; if ($done) { return; } $done = true; $table = EP_Feature_Service::get_table_name(); $exists = $wpdb->get_var("SHOW COLUMNS FROM {$table} LIKE '" . self::HASH_COLUMN . "'"); if (!$exists) { $wpdb->query("ALTER TABLE {$table} ADD COLUMN " . self::HASH_COLUMN . " TEXT NULL"); } } /** * 日本語原文のハッシュ。前後の空白差だけで再翻訳しないよう trim してから取る。 */ private static function source_hash(string $ja): string { return sha1(trim($ja)); } /** * 訳が日本語と同じ「形」をしているか比べるための署名。 * * ハッシュを持たない既存行(=この仕組みより前に手作業で入れた訳)を * 作り直すべきか判断するのに使う。HTML はブロック要素の数、FAQ は件数。 * プレーンテキストは比べる形が無いので空文字=一致扱い(既存訳を尊重する)。 */ private static function structure_signature(string $kind, string $value): string { if ($kind === 'faq') { $items = json_decode($value, true); return 'faq:' . (is_array($items) ? count($items) : '?'); } if ($kind === 'html') { $parts = []; foreach (['h2', 'h3', 'li', 'p', 'table', 'img'] as $tag) { $parts[] = $tag . preg_match_all('/<' . $tag . '\b/i', $value); } return implode(',', $parts); } return ''; } /** * どの(フィールド×言語)が翻訳を要るか調べる。APIは叩かない。 * * 判定は3段階。 * 1. 訳が空 → 要翻訳 * 2. 控えたハッシュ ≠ 今の日本語 → 要翻訳(日本語を書き換えた後) * 3. ハッシュが無い(この仕組み以前の手作業の訳) * → **形が違えば**要翻訳。同じなら現行訳を採用してハッシュだけ控える * * @return array{needed:array, adopt:array, clear:array} */ public static function plan($row, bool $force = false): array { $hashes = self::read_hashes($row); $needed = []; // suffix => [base, ...] $adopt = []; // col => hash(訳はそのまま使う。ハッシュだけ控える) $clear = []; // col => ''(日本語が空になったので訳も消す) foreach (self::FIELDS as $base => $kind) { $ja = trim((string) ($row->{$base} ?? '')); foreach (array_keys(self::LANGS) as $suffix) { $col = $base . '_' . $suffix; if (!property_exists($row, $col)) { continue; } $current = trim((string) ($row->{$col} ?? '')); // 日本語が空 → 訳も消す(消したはずの内容が多言語ページに出続けるのを防ぐ) if ($ja === '') { if ($current !== '') { $clear[$col] = ''; } continue; } $hash = self::source_hash($ja); if ($force || $current === '') { $needed[$suffix][] = $base; continue; } if (isset($hashes[$col])) { if ($hashes[$col] !== $hash) { $needed[$suffix][] = $base; } continue; } // ハッシュ未記録=この仕組み以前の訳。形が合っていれば尊重する。 if (self::structure_signature($kind, $ja) === self::structure_signature($kind, $current)) { $adopt[$col] = $hash; } else { $needed[$suffix][] = $base; } } } return ['needed' => $needed, 'adopt' => $adopt, 'clear' => $clear]; } /** * 1特集を翻訳する。足りない・古い(言語×フィールド)だけを埋める。 * * @param string[] $keep 今回の保存で人が入れた訳のカラム名。日本語と同時に * 更新されたものとして扱い、上書きしない(管理画面用)。 * @return array{updated:string[],skipped:bool,error:string} */ public static function sync(int $feature_id, bool $force = false, array $keep = []): array { global $wpdb; $result = ['updated' => [], 'skipped' => true, 'error' => '']; if ($feature_id <= 0) { $result['error'] = 'FEATURE_ID が不正です'; return $result; } self::ensure_columns(); $table = EP_Feature_Service::get_table_name(); $row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE FEATURE_ID = %d LIMIT 1", $feature_id)); if (!$row) { $result['error'] = 'FEATURE_ID ' . $feature_id . ' が見つかりません'; return $result; } $plan = self::plan($row, $force); $hashes = self::read_hashes($row); // 人が入れた訳は「今の日本語に対する訳」として扱う(同じ保存で上書きしない) foreach ($keep as $col) { $base = self::base_of($col); if ($base === '' || trim((string) ($row->{$col} ?? '')) === '') { continue; } $ja = trim((string) ($row->{$base} ?? '')); if ($ja === '') { continue; } $hashes[$col] = self::source_hash($ja); $plan['adopt'][$col] = $hashes[$col]; foreach ($plan['needed'] as $suffix => $bases) { if ($col === $base . '_' . $suffix) { $plan['needed'][$suffix] = array_values(array_diff($bases, [$base])); if (!$plan['needed'][$suffix]) { unset($plan['needed'][$suffix]); } } } } $update = $plan['clear']; foreach (array_keys($plan['clear']) as $col) { unset($hashes[$col]); $result['updated'][] = $col . '(clear)'; } $hashes = array_merge($hashes, $plan['adopt']); $needed = array_filter($plan['needed']); if ($needed) { $sources = []; foreach ($needed as $bases) { foreach ($bases as $base) { $sources[$base] = trim((string) ($row->{$base} ?? '')); } } $payload = self::build_source_payload($sources); if (!$payload) { $result['error'] = '翻訳対象を組み立てられませんでした'; return $result; } $translated = self::translate($payload, array_keys($needed)); if (!$translated) { $result['error'] = '翻訳APIから使える応答が得られませんでした'; return $result; } foreach ($needed as $suffix => $bases) { foreach ($bases as $base) { $value = self::rebuild_value( self::FIELDS[$base], $sources[$base], $translated[$suffix][$base] ?? null, $suffix ); if ($value === null) { continue; } $col = $base . '_' . $suffix; $update[$col] = $value; // ⚠️ ハッシュを控えるのは**保存できた訳だけ**。捨てた訳にまで // 控えると「古い訳+新しいハッシュ」になり、二度と再翻訳されない。 $hashes[$col] = self::source_hash($sources[$base]); $result['updated'][] = $col; } } } $stored = self::read_hashes($row); if ($update || $hashes !== $stored) { $update[self::HASH_COLUMN] = (string) wp_json_encode($hashes, JSON_UNESCAPED_SLASHES); $wpdb->update($table, $update, ['FEATURE_ID' => $feature_id]); $result['skipped'] = empty($result['updated']); } return $result; } /** * 控えてあるハッシュ表を読む。壊れていたら空扱い(再翻訳になるだけで害は無い)。 * * @return array */ private static function read_hashes($row): array { $raw = (string) ($row->{self::HASH_COLUMN} ?? ''); if ($raw === '') { return []; } $decoded = json_decode($raw, true); return is_array($decoded) ? array_map('strval', $decoded) : []; } /** `FEATURE_ARTICLE_EN` → `FEATURE_ARTICLE`。対象外の列は空文字。 */ private static function base_of(string $col): string { foreach (self::FIELDS as $base => $kind) { foreach (array_keys(self::LANGS) as $suffix) { if ($col === $base . '_' . $suffix) { return $base; } } } return ''; } /** * モデルへ渡す形へ整える。FAQ だけは JSON を解いて q/a の配列で渡す * (JSON 文字列のまま訳させると構造が壊れた応答が混ざるため)。 * * @param array $sources * @return array */ private static function build_source_payload(array $sources): array { $payload = []; foreach ($sources as $base => $ja) { if (self::FIELDS[$base] === 'faq') { $items = json_decode($ja, true); if (!is_array($items) || !$items) { continue; } $pairs = []; foreach ($items as $item) { $pairs[] = [ 'q' => (string) ($item['q'] ?? ''), 'a' => (string) ($item['a'] ?? ''), ]; } $payload[$base] = $pairs; continue; } $payload[$base] = mb_substr($ja, 0, self::MAX_SOURCE_LENGTH, 'UTF-8'); } return $payload; } /** * 訳を保存できる形に戻す。使えない訳(かな混じり・件数不一致)は null を返して捨てる。 */ private static function rebuild_value(string $kind, string $source_ja, $translated, string $suffix): ?string { if ($kind === 'faq') { $items = json_decode($source_ja, true); if (!is_array($items) || !is_array($translated) || count($translated) !== count($items)) { return null; } $out = []; foreach (array_values($items) as $index => $item) { $q = trim((string) ($translated[$index]['q'] ?? '')); $a = trim((string) ($translated[$index]['a'] ?? '')); if ($q === '' || $a === '' || !self::is_usable($q, $suffix) || !self::is_usable($a, $suffix)) { return null; } $out[] = ['q' => $q, 'a' => $a]; } // 日本語カラムと同じく「エスケープしない UTF-8 の JSON 配列」で保存する return (string) wp_json_encode($out, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } if (!is_string($translated)) { return null; } $value = trim($translated); if ($value === '' || !self::is_usable($value, $suffix)) { return null; } return $kind === 'html' ? wp_kses_post($value) : sanitize_textarea_field($value); } /** * 「訳せている」か。かなが残っていたら日本語がそのまま返ってきているので捨てる。 * ⚠️ 漢字での判定はできない(中国語は当然漢字だけになる)。 */ private static function is_usable(string $text, string $suffix): bool { if (preg_match('/[\x{3040}-\x{309F}\x{30A0}-\x{30FA}\x{30FC}]/u', $text)) { return false; } if ($suffix === 'EN' && preg_match('/[\x{4E00}-\x{9FFF}]/u', $text)) { // 英語なのに漢字が主体なら訳せていない。固有名詞1〜2文字は許す。 preg_match_all('/[\x{4E00}-\x{9FFF}]/u', $text, $m); if (count($m[0]) > mb_strlen($text) * 0.2) { return false; } } return true; } /** * 必要な言語をまとめて1リクエストで訳す。 * * @param array $payload * @param string[] $suffixes * @return array> suffix => base => 訳。失敗時は空配列 */ private static function translate(array $payload, array $suffixes): array { if (!class_exists('EP_Chatbot_Service')) { return []; } $api_key = EP_Chatbot_Service::get_api_key(); if ($api_key === '') { error_log('[EP Feature I18n] APIキーが未設定のため翻訳をスキップしました'); return []; } $lang_lines = []; foreach ($suffixes as $suffix) { $lang_lines[] = $suffix . ' = ' . (self::LANGS[$suffix] ?? $suffix); } $instruction = 'You translate Japanese real-estate website copy about tower apartments in Osaka. ' . 'Return JSON only. Top-level keys must be exactly: ' . implode(', ', $suffixes) . ' (' . implode('; ', $lang_lines) . '). ' . 'Each value is an object with the same keys and the same shape as the input object. ' . 'FEATURE_FAQ is an array of {"q","a"} objects: translate every element and keep the array length and order identical. ' . 'FEATURE_ARTICLE contains HTML: preserve every tag and its nesting exactly, translate only the text. ' . 'Keep all numbers, station names, building names and units accurate; do not add or drop information. ' . 'Write natural marketing copy for the target language, not a literal gloss. ' // ⚠️ 屋号は言語ごとに決まっている(CLAUDE.md「ユーザーに見える表示テキストに // EstatePress を使わない」)。訳語を任せると Premium Real Estate 等に揺れる。 . 'Brand name glossary: 「プレミアム不動産」 = "Premium Fudosan" in English, ' . '"Premium不动产" in Simplified Chinese. Never write "EstatePress". ' . 'Never leave Japanese characters in the output.'; $body = [ 'model' => EP_Chatbot_Service::get_global_val('translation_model', self::DEFAULT_MODEL), 'messages' => [ ['role' => 'system', 'content' => $instruction], ['role' => 'user', 'content' => (string) wp_json_encode($payload, JSON_UNESCAPED_UNICODE)], ], 'temperature' => 0.2, 'response_format' => ['type' => 'json_object'], ]; $post = static function (array $request) use ($api_key) { return wp_remote_post(self::OPENAI_API_URL, [ 'body' => wp_json_encode($request), 'headers' => [ 'Content-Type' => 'application/json', 'Authorization' => 'Bearer ' . $api_key, ], 'timeout' => 120, ]); }; $response = $post($body); if (is_wp_error($response)) { error_log('[EP Feature I18n] 翻訳リクエスト失敗: ' . $response->get_error_message()); return []; } $status = (int) wp_remote_retrieve_response_code($response); $raw = (string) wp_remote_retrieve_body($response); // ⚠️ gpt-5系は temperature の指定自体を 400 で拒否する(住戸コメント側と同じ対処) if ($status === 400 && isset($body['temperature']) && stripos($raw, 'temperature') !== false) { unset($body['temperature']); $response = $post($body); if (is_wp_error($response)) { error_log('[EP Feature I18n] 翻訳リクエスト失敗(再試行): ' . $response->get_error_message()); return []; } $status = (int) wp_remote_retrieve_response_code($response); $raw = (string) wp_remote_retrieve_body($response); } if ($status >= 400) { error_log('[EP Feature I18n] 翻訳 HTTP ' . $status . ': ' . mb_substr($raw, 0, 300, 'UTF-8')); return []; } $decoded = json_decode($raw, true); $content = $decoded['choices'][0]['message']['content'] ?? ''; if (!is_string($content) || $content === '') { return []; } $parsed = json_decode($content, true); if (!is_array($parsed)) { error_log('[EP Feature I18n] 応答がJSONとして読めませんでした: ' . mb_substr($content, 0, 200, 'UTF-8')); return []; } $out = []; foreach ($suffixes as $suffix) { if (isset($parsed[$suffix]) && is_array($parsed[$suffix])) { $out[$suffix] = $parsed[$suffix]; } } return $out; } /** * WP-CLI: `wp ep-feature translate [--id=] [--force] [--dry-run]` * * ⚠️ オプション名に `--user` を使わないこと(WP-CLI のグローバルパラメータに食われる。 * 2026-08-23 の一斉誤送信の原因)。 */ public static function cli_translate(array $args, array $assoc): void { global $wpdb; $table = EP_Feature_Service::get_table_name(); $force = isset($assoc['force']); $dry = isset($assoc['dry-run']); $only = isset($assoc['id']) ? (int) $assoc['id'] : 0; $where = 'FEATURE_DEL_FLG = 0'; if ($only > 0) { $where .= $wpdb->prepare(' AND FEATURE_ID = %d', $only); } else { $where .= ' AND FEATURE_PUB_FLG = 1'; } $ids = $wpdb->get_col("SELECT FEATURE_ID FROM {$table} WHERE {$where} ORDER BY FEATURE_ID"); if (!$ids) { WP_CLI::warning('対象の特集がありません'); return; } self::ensure_columns(); $done = 0; $failed = 0; foreach ($ids as $id) { $id = (int) $id; if ($dry) { // 何が古いのかを見せる(APIは叩かない) $row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE FEATURE_ID = %d LIMIT 1", $id)); if (!$row) { continue; } $plan = self::plan($row, $force); $cols = []; foreach ($plan['needed'] as $suffix => $bases) { foreach ($bases as $base) { $cols[] = $base . '_' . $suffix; } } foreach (array_keys($plan['clear']) as $col) { $cols[] = $col . '(clear)'; } if ($cols) { $done++; WP_CLI::log(sprintf('[dry-run] FEATURE_ID %d (%s): %s', $id, $row->FEATURE_SLUG, implode(', ', $cols))); } continue; } $res = self::sync($id, $force); if ($res['error'] !== '') { $failed++; WP_CLI::warning(sprintf('FEATURE_ID %d: %s', $id, $res['error'])); continue; } if ($res['skipped']) { continue; } $done++; WP_CLI::log(sprintf('FEATURE_ID %d: %s', $id, implode(', ', $res['updated']))); } WP_CLI::success(sprintf( '%s%d件 / %d件失敗 / 対象%d件', $dry ? '[dry-run] 要翻訳 ' : '更新 ', $done, $failed, count($ids) )); } }