'Claude Haiku 4.5 (高速・低コスト)', 'claude-sonnet-4-6' => 'Claude Sonnet 4.6 (バランス・推奨)', 'claude-opus-4-6' => 'Claude Opus 4.6 (最高性能)', ); const GEMINI_MODELS = array( 'gemini-2.5-flash-lite' => 'Gemini 2.5 Flash-Lite (超高速・超低コスト)', 'gemini-2.5-flash' => 'Gemini 2.5 Flash (バランス・推奨)', 'gemini-2.5-pro' => 'Gemini 2.5 Pro (最高性能)', ); /** * シングルトンインスタンスの取得 */ public static function get_instance() { if (null === self::$instance) { self::$instance = new self(); } return self::$instance; } /** * コンストラクタ */ private function __construct() { add_action('wp_ajax_aagl_generate_subkeywords', array($this, 'ajax_generate_subkeywords')); add_action('wp_ajax_aagl_test_api_connection', array($this, 'ajax_test_api_connection')); add_action('wp_ajax_aagl_verify_admin_pass', array($this, 'ajax_verify_admin_pass')); } /** * Claude APIでサブキーワードを生成 * * @param string $main_keyword メインキーワード * @param int $count 生成数(デフォルト: 10) * @param string $category カテゴリ(オプション) * @return array 生成結果 */ public function generate_subkeywords_claude($main_keyword, $count = 10, $category = '') { $api_key = $this->get_api_key('claude_api_key'); if (empty($api_key)) { return array( 'success' => false, 'error' => 'Claude APIキーが設定されていません。' ); } // プロンプトの構築 $prompt = $this->build_subkeyword_prompt($main_keyword, $count, $category); // APIリクエストデータ $claude_model = $this->get_claude_model(); $request_data = array( 'model' => $claude_model, 'max_tokens' => 1000, 'messages' => array( array( 'role' => 'user', 'content' => $prompt ) ) ); // Sonnet 5 / Opus 5 / Opus 4.7以降は temperature を受け付けない(400: `temperature` is deprecated for this model) if (AAGL_Utilities::claude_model_accepts_temperature($claude_model)) { $request_data['temperature'] = 0.7; } // 5系は既定の適応的思考が max_tokens を食い、text ブロックが返らないことがあるため思考を無効化 $thinking_param = AAGL_Utilities::claude_thinking_param($claude_model); if (null !== $thinking_param) { $request_data['thinking'] = $thinking_param; } // HTTPリクエスト実行 $response = $this->send_claude_request($request_data, $api_key); if (!$response['success']) { return $response; } // レスポンス解析 return $this->parse_subkeyword_response($response['data'], 'claude'); } /** * 日本語キーワードを英語に翻訳 * * @param string $japanese_text 日本語テキスト * @return array 翻訳結果 ['success' => bool, 'translated' => string] */ public function translate_keyword($japanese_text) { try { // 既に英語の場合はそのまま返す if (preg_match('/^[a-zA-Z0-9\s\-_]+$/', $japanese_text)) { return array( 'success' => true, 'translated' => $japanese_text ); } // 翻訳プロンプト $prompt = "以下の日本語を英語に翻訳してください。翻訳結果のみを出力し、説明や余計な文章は含めないでください。\n\n日本語: {$japanese_text}\n\n英語:"; // Gemini APIを使用して翻訳 $response = $this->call_gemini_api($prompt, 100, 'gemini-2.5-flash-lite'); if ($response['success'] && !empty($response['content'])) { $translated = trim($response['content']); AAGL_Utilities::log("Translated '{$japanese_text}' to '{$translated}'"); return array( 'success' => true, 'translated' => $translated ); } else { AAGL_Utilities::log('Translation failed, using original keyword: ' . $japanese_text, 'error'); return array( 'success' => false, 'translated' => $japanese_text ); } } catch (Exception $e) { AAGL_Utilities::log('Translation exception: ' . $e->getMessage(), 'error'); return array( 'success' => false, 'translated' => $japanese_text ); } } /** * Gemini APIでサブキーワードを生成 * * @param string $main_keyword メインキーワード * @param int $count 生成数(デフォルト: 10) * @param string $category カテゴリ(オプション) * @return array 生成結果 */ public function generate_subkeywords_gemini($main_keyword, $count = 10, $category = '') { $api_key = $this->get_api_key('gemini_api_key'); if (empty($api_key)) { return array( 'success' => false, 'error' => 'Gemini APIキーが設定されていません。' ); } // プロンプトの構築 $prompt = $this->build_subkeyword_prompt($main_keyword, $count, $category); // APIリクエストデータ $request_data = array( 'contents' => array( array( 'parts' => array( array('text' => $prompt) ) ) ), 'generationConfig' => array( 'temperature' => 0.7, 'maxOutputTokens' => 1000, 'topP' => 0.8, 'topK' => 40 ), 'safetySettings' => array( array( 'category' => 'HARM_CATEGORY_HARASSMENT', 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE' ), array( 'category' => 'HARM_CATEGORY_HATE_SPEECH', 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE' ) ) ); // HTTPリクエスト実行 $response = $this->send_gemini_request($request_data, $api_key); if (!$response['success']) { return $response; } // レスポンス解析 return $this->parse_subkeyword_response($response['data'], 'gemini'); } /** * Claude APIリクエストの送信 * * @param array $request_data リクエストデータ * @param string $api_key APIキー * @return array レスポンス */ private function send_claude_request($request_data, $api_key) { $args = array( 'method' => 'POST', 'headers' => array( 'Content-Type' => 'application/json', 'x-api-key' => $api_key, 'anthropic-version' => '2023-06-01' ), 'body' => wp_json_encode($request_data), 'timeout' => defined('AAGL_TIMEOUT_AI_API') ? AAGL_TIMEOUT_AI_API : 120, 'sslverify' => true ); $response = wp_remote_request($this->claude_api_url, $args); return $this->process_api_response($response, 'claude'); } /** * Gemini APIリクエストの送信 * * @param array $request_data リクエストデータ * @param string $api_key APIキー * @return array レスポンス */ private function send_gemini_request($request_data, $api_key) { $url = $this->get_gemini_api_url(); $args = array( 'method' => 'POST', 'headers' => array( 'Content-Type' => 'application/json', 'x-goog-api-key' => $api_key, ), 'body' => wp_json_encode($request_data), 'timeout' => defined('AAGL_TIMEOUT_AI_API') ? AAGL_TIMEOUT_AI_API : 120, 'sslverify' => true ); $response = wp_remote_request($url, $args); return $this->process_api_response($response, 'gemini'); } /** * APIレスポンスの処理 * * @param mixed $response HTTP レスポンス * @param string $provider API プロバイダー * @return array 処理結果 */ private function process_api_response($response, $provider) { if (is_wp_error($response)) { return array( 'success' => false, 'error' => 'API接続エラー: ' . $response->get_error_message() ); } $http_code = wp_remote_retrieve_response_code($response); $body = wp_remote_retrieve_body($response); if (200 !== $http_code) { $error_data = json_decode($body, true); $error_msg = isset($error_data['error']['message']) ? $error_data['error']['message'] : 'API エラー'; return array( 'success' => false, 'error' => sprintf('%s API エラー (HTTP %d): %s', ucfirst($provider), $http_code, $error_msg) ); } $data = json_decode($body, true); if (JSON_ERROR_NONE !== json_last_error()) { return array( 'success' => false, 'error' => 'API レスポンスの解析に失敗しました。' ); } return array( 'success' => true, 'data' => $data ); } /** * サブキーワード生成用プロンプトの構築 * * @param string $main_keyword メインキーワード * @param int $count 生成数 * @param string $category カテゴリ * @return string プロンプト */ private function build_subkeyword_prompt($main_keyword, $count, $category = '') { $category_text = !empty($category) ? "カテゴリ: {$category}\n" : ''; return "メインキーワード「{$main_keyword}」に関連する検索ボリュームの高いサブキーワードを{$count}個生成してください。\n\n" . "{$category_text}" . "条件:\n" . "1. SEO効果が期待できる具体的なキーワード\n" . "2. ユーザーの検索意図を満たすもの\n" . "3. 競合性と検索ボリュームのバランスが良いもの\n" . "4. ロングテールキーワードも含む\n\n" . "出力形式:\n" . "1つのキーワードを1行に記載し、余分な説明は不要です。\n" . "例:\n" . "WordPress プラグイン 作り方\n" . "WordPress カスタマイズ 初心者\n" . "WordPress テーマ おすすめ"; } /** * サブキーワードレスポンスの解析 * * @param array $response_data APIレスポンスデータ * @param string $provider APIプロバイダー * @return array 解析結果 */ private function parse_subkeyword_response($response_data, $provider) { try { $content = ''; if ('claude' === $provider) { // Sonnet 5 以降は thinking ブロックが先頭に来るため text ブロックを探して取り出す $content = AAGL_Utilities::extract_claude_text($response_data); } elseif ('gemini' === $provider) { if (isset($response_data['candidates'][0]['content']['parts'][0]['text'])) { $content = $response_data['candidates'][0]['content']['parts'][0]['text']; } } if (empty($content)) { return array( 'success' => false, 'error' => 'APIレスポンスからキーワードを取得できませんでした。' ); } // キーワードを行単位で分割 $lines = array_filter(array_map('trim', explode("\n", $content))); $keywords = array(); foreach ($lines as $line) { // 番号や記号を除去 $keyword = preg_replace('/^[0-9]+[\.\)]\s*/', '', $line); $keyword = preg_replace('/^[\-\*]\s*/', '', $keyword); $keyword = trim($keyword); if (!empty($keyword) && mb_strlen($keyword) > 2) { $keywords[] = $keyword; } } return array( 'success' => true, 'keywords' => $keywords, 'count' => count($keywords), 'provider' => $provider ); } catch (Exception $e) { return array( 'success' => false, 'error' => 'キーワードの解析中にエラーが発生しました: ' . $e->getMessage() ); } } /** * 禁止・推奨表現からプロンプト制御指示を構築 * * @param array|null $template テンプレートオブジェクト * @return string 追加指示文(空文字列の場合あり) */ public static function build_expression_control_instruction($template) { if (empty($template) || empty($template['settings'])) { return ''; } $settings = $template['settings']; $forbidden = trim($settings['forbidden_expressions'] ?? ''); $recommended = trim($settings['recommended_expressions'] ?? ''); if (empty($forbidden) && empty($recommended)) { return ''; } $instruction = "\n\n## 【文章表現ルール】\n"; if (!empty($forbidden)) { $items = array_filter(array_map('trim', explode("\n", $forbidden))); if (!empty($items)) { $instruction .= "以下の表現は使用禁止です:\n"; foreach ($items as $item) { $instruction .= "- 「{$item}」\n"; } } } if (!empty($recommended)) { $items = array_filter(array_map('trim', explode("\n", $recommended))); if (!empty($items)) { $instruction .= "以下の表現を積極的に使用してください:\n"; foreach ($items as $item) { $instruction .= "- 「{$item}」\n"; } } } return $instruction; } /** * 記事コンテンツからメタディスクリプションを自動生成 * * @param string $content HTML記事コンテンツ * @param int $max_length 最大文字数(デフォルト120) * @return string メタディスクリプション */ public static function generate_meta_description($content, $max_length = 120) { if (empty($content)) { return ''; } // HTMLタグを除去 $text = wp_strip_all_tags($content); // 改行・連続空白を正規化 $text = preg_replace('/\s+/', ' ', $text); $text = trim($text); if (empty($text)) { return ''; } // 最初の意味のある文を抽出(。で区切り) $sentences = preg_split('/(?<=。)/', $text, -1, PREG_SPLIT_NO_EMPTY); $description = ''; foreach ($sentences as $sentence) { $sentence = trim($sentence); if (empty($sentence)) { continue; } if (mb_strlen($description . $sentence) <= $max_length) { $description .= $sentence; } else { if (empty($description)) { // 最初の文が長すぎる場合はトランケート $description = mb_substr($sentence, 0, $max_length - 3) . '...'; } break; } } return $description; } /** * 生成記事の品質バリデーション * * @param string $content 記事HTML * @param array $params 生成パラメータ(main_keyword等) * @param int $min_length 最小文字数(デフォルト800) * @return array ['passed' => bool, 'failures' => array of strings] */ public static function validate_article_quality($content, $params = array(), $min_length = 800) { $failures = array(); $plain_text = wp_strip_all_tags($content); // 1. 空コンテンツチェック if (empty(trim($plain_text))) { return array('passed' => false, 'failures' => array('記事が空です')); } // 2. 最小文字数チェック $char_count = mb_strlen($plain_text); if ($char_count < $min_length) { $failures[] = "文字数不足({$char_count}文字 / 最低{$min_length}文字)"; } // 3. H2見出し数チェック(最低2つ) $h2_count = preg_match_all('/
]/i', $content); if ($p_count < 3) { $failures[] = "段落不足({$p_count}個 / 最低3個)"; } // 5. メインキーワード含有チェック(スペース区切りの各単語が含まれているか) $main_keyword = $params['main_keyword'] ?? ''; if (!empty($main_keyword)) { $keyword_parts = preg_split('/[\s ]+/u', trim($main_keyword)); $missing_parts = array(); foreach ($keyword_parts as $part) { if (!empty($part) && mb_strpos($plain_text, $part) === false) { $missing_parts[] = $part; } } if (!empty($missing_parts)) { $failures[] = "メインキーワード「{$main_keyword}」の語句(" . implode('、', $missing_parts) . ")が本文に含まれていません"; } } // 6. 禁止表現チェック(テンプレート設定駆動・決定論的検証) // プロンプト指示の遵守はモデル任せのため、機械チェックで担保しリトライのフィードバックに載せる $forbidden_raw = $params['template']['settings']['forbidden_expressions'] ?? ''; if (!empty($forbidden_raw)) { $forbidden_items = array_filter(array_map('trim', explode("\n", str_replace("\r\n", "\n", $forbidden_raw)))); $found = array(); foreach ($forbidden_items as $item) { if ('' !== $item && false !== mb_strpos($plain_text, $item)) { $found[] = $item; } } if (!empty($found)) { $failures[] = '禁止表現が本文に含まれています。該当表現を一切使わずに書き直してください: 「' . implode('」「', $found) . '」'; } } return array( 'passed' => empty($failures), 'failures' => $failures, ); } /** * 禁止表現を含まない最初のタイトル候補を選ぶ * * 全候補が禁止表現を含む場合は先頭候補を返す(生成自体は止めない)。 * * @param array $candidates タイトル候補 * @param string $forbidden_raw 禁止表現(改行区切り) * @return string 採用タイトル */ public static function select_title_avoiding_forbidden($candidates, $forbidden_raw) { $candidates = array_values(array_filter((array) $candidates)); if (empty($candidates)) { return ''; } $forbidden_items = array_filter(array_map('trim', explode("\n", str_replace("\r\n", "\n", (string) $forbidden_raw)))); if (empty($forbidden_items)) { return $candidates[0]; } foreach ($candidates as $candidate) { $clean = true; foreach ($forbidden_items as $item) { if ('' !== $item && false !== mb_strpos($candidate, $item)) { $clean = false; break; } } if ($clean) { return $candidate; } } AAGL_Utilities::log('Title selection: 全候補が禁止表現を含むため先頭候補を採用', 'warning'); return $candidates[0]; } /** * バリデーション失敗時のフィードバック付きプロンプト構築 * * @param string $original_prompt 元のプロンプト * @param array $failures バリデーション失敗項目 * @param string $previous_content 前回生成されたコンテンツ(参考用) * @return string フィードバック付きプロンプト */ private function build_retry_prompt($original_prompt, $failures, $previous_content = '') { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed $feedback = "\n\n【重要:再生成指示】\n"; $feedback .= "前回の生成結果に以下の品質問題がありました。これらを修正して再度記事を生成してください。\n\n"; foreach ($failures as $i => $failure) { $feedback .= ($i + 1) . ". " . $failure . "\n"; } $feedback .= "\n上記の問題をすべて解決した上で、記事全体を最初から生成し直してください。\n"; $feedback .= "特に文字数が不足している場合は、各セクションの解説を充実させ、具体例・データ・読者へのアドバイスを追加して文字数を確保してください。\n"; $feedback .= "HTMLタグのみで出力し、```html等のコードフェンスは使わないでください。\n"; return $original_prompt . $feedback; } /** * 関連記事リンクセクションを生成(内部リンク自動挿入) * * 生成済み記事から関連性の高い記事を取得し、 * 記事末尾に追加するHTMLセクションを返す。 * * @param string $main_keyword メインキーワード * @param int $exclude_post_id 除外する投稿ID(自分自身) * @param int $max_links 最大リンク数(デフォルト5) * @return string 関連記事HTMLセクション(該当なしの場合は空文字) */ public static function generate_related_posts_section($main_keyword, $exclude_post_id = 0, $max_links = 5) { if (empty($main_keyword)) { return ''; } // 生成記事のみを検索(_aagl_generated = 'yes') $aagl_post_type = AAGL_Utilities::get_post_type_setting(); $args = array( 'post_type' => $aagl_post_type, 'post_status' => array('publish', 'draft'), 'posts_per_page' => $max_links + 1, // 自分自身を除外する余裕 'post__not_in' => $exclude_post_id > 0 ? array($exclude_post_id) : array(), 'meta_query' => array( array( 'key' => '_aagl_generated', 'value' => 'yes', ), ), 's' => $main_keyword, 'orderby' => 'relevance', ); $query = new WP_Query($args); if (!$query->have_posts()) { // キーワード検索で見つからない場合、同カテゴリの最新記事で代替 $categories = array(); if ($exclude_post_id > 0) { $categories = wp_get_post_categories($exclude_post_id); } if (!empty($categories)) { $args = array( 'post_type' => $aagl_post_type, 'post_status' => array('publish', 'draft'), 'posts_per_page' => $max_links, 'post__not_in' => $exclude_post_id > 0 ? array($exclude_post_id) : array(), 'meta_query' => array( array( 'key' => '_aagl_generated', 'value' => 'yes', ), ), 'orderby' => 'date', 'order' => 'DESC', ); // 固定ページはカテゴリ非対応のためメタデータのみで絞り込み if ($aagl_post_type === 'post') { $args['category__in'] = $categories; } $query = new WP_Query($args); } } if (!$query->have_posts()) { wp_reset_postdata(); return ''; } $links = array(); $links_count = 0; while ($query->have_posts() && $links_count < $max_links) { $query->the_post(); $links[] = array( 'title' => get_the_title(), 'url' => get_permalink(), ); ++$links_count; } wp_reset_postdata(); if (empty($links)) { return ''; } // HTMLセクション構築 $html = "\n\n\n"; $html .= '
' . "\n"; $html .= '' . "\n"; return $html; } /** * 参考コンテンツをキーワード関連性ベースで要約抽出 * * 単純な先頭トランケーションではなく、メインキーワードに関連する * 段落を優先的に抽出し、より価値の高い参考情報を構築する。 * * @param string $content 参考コンテンツ全文 * @param string $keyword メインキーワード * @param int $max_length 最大文字数(デフォルト1500) * @return string 要約抽出されたコンテンツ */ public static function extract_relevant_content($content, $keyword = '', $max_length = 1500) { if (empty($content)) { return ''; } // HTMLタグ除去・正規化 $text = wp_strip_all_tags($content); $text = preg_replace('/\s+/', ' ', $text); $text = trim($text); if (mb_strlen($text) <= $max_length) { return $text; } // キーワードが空の場合は先頭トランケーション if (empty($keyword)) { return mb_substr($text, 0, $max_length) . '...'; } // 段落に分割(。で区切り、短すぎるものは除外) $sentences = preg_split('/(?<=。)/', $text, -1, PREG_SPLIT_NO_EMPTY); if (empty($sentences)) { return mb_substr($text, 0, $max_length) . '...'; } // キーワードを分割(スペース区切りの複合キーワードに対応) $keyword_parts = preg_split('/[\s ]+/', $keyword, -1, PREG_SPLIT_NO_EMPTY); // 各文にスコアを付与 $scored = array(); foreach ($sentences as $i => $sentence) { $sentence = trim($sentence); if (mb_strlen($sentence) < 10) { continue; } $score = 0; // 完全一致ボーナス if (mb_strpos($sentence, $keyword) !== false) { $score += 10; } // 部分一致 foreach ($keyword_parts as $part) { if (mb_strpos($sentence, $part) !== false) { $score += 3; } } // 位置ボーナス(最初と最後の文は重要度が高い) if ($i < 3) { $score += 2; } $scored[] = array( 'text' => $sentence, 'score' => $score, 'index' => $i, ); } // スコア順にソート(同スコアなら元の順序を維持) usort($scored, function ($a, $b) { if ($b['score'] === $a['score']) { return $a['index'] - $b['index']; } return $b['score'] - $a['score']; }); // 上位の文を元の順序で再構成 $selected = array(); $total_length = 0; foreach ($scored as $item) { $len = mb_strlen($item['text']); if ($total_length + $len > $max_length) { continue; } $selected[] = $item; $total_length += $len; } // 元の出現順に並べ直す usort($selected, function ($a, $b) { return $a['index'] - $b['index']; }); $result = implode('', array_column($selected, 'text')); if (mb_strlen($result) < mb_strlen($text)) { $result .= '...'; } return $result; } /** * API接続テスト * * @param string $provider APIプロバイダー(claude/gemini) * @return array テスト結果 */ public function test_api_connection($provider) { $test_prompt = "Hello"; if ('claude' === $provider) { $api_key = $this->get_api_key('claude_api_key'); if (empty($api_key)) { return array( 'success' => false, 'error' => 'Claude APIキーが設定されていません。設定画面でAPIキーを入力してください。', ); } $request_data = array( 'model' => $this->get_claude_model(), 'max_tokens' => 10, 'messages' => array( array( 'role' => 'user', 'content' => $test_prompt ) ) ); $response = $this->send_claude_request($request_data, $api_key); } elseif ('gemini' === $provider) { $api_key = $this->get_api_key('gemini_api_key'); if (empty($api_key)) { return array( 'success' => false, 'error' => 'Gemini APIキーが設定されていません。', ); } $request_data = array( 'contents' => array( array( 'parts' => array( array('text' => $test_prompt) ) ) ), 'generationConfig' => array( 'maxOutputTokens' => 10 ) ); $response = $this->send_gemini_request($request_data, $api_key); // デバッグ情報を追加 if (!$response['success'] && isset($debug_info)) { $response['debug'] = $debug_info; } } else { return array( 'success' => false, 'error' => '無効なAPIプロバイダーです。' ); } if ($response['success']) { return array( 'success' => true, 'message' => ucfirst($provider) . ' API接続に成功しました。' ); } else { return $response; } } /** * 構造化記事生成(プロバイダ自動判定+横断フォールバック) * * 記事生成の入口をここに一本化する。狙いは2つ。 * 1. 「自動」設定を含むプロバイダ判定を1箇所に集約する * 2. 片方のAIがモデル廃止で使えなくなっても、もう片方で記事を出し切る * * 横断フォールバックは「モデルが使えない」場合だけに限定する。 * 一時的な混雑(429)や通信エラーで別AIに切り替えると、顧客が気づかないまま * 文体の異なる記事が混ざり続けるため、恒久的な障害に限って発動させる。 * * @param array $params 生成パラメータ * @return array 生成結果 */ public function generate_structured_article($params) { $provider = AAGL_Utilities::determine_provider($params['provider'] ?? null); $params['provider'] = $provider; $result = $this->generate_structured_article_by_provider($provider, $params); if (!empty($result['success'])) { return $result; } // ここから先はフォールバック判定 if (!class_exists('AAGL_Model_Registry') || !AAGL_Model_Registry::is_provider_fallback_enabled()) { return $result; } $error_code = $result['error_code'] ?? ''; if ('model_unavailable' !== $error_code && 'no_api_key' !== $error_code) { return $result; } $other = AAGL_Model_Registry::other_provider($provider); if (empty(AAGL_Utilities::get_api_key($other . '_api_key'))) { return $result; // 切替先のキーが無ければ何もできない } AAGL_Utilities::log( sprintf('Provider fallback: %s -> %s (%s)', $provider, $other, $error_code), 'warning' ); $params['provider'] = $other; $alt = $this->generate_structured_article_by_provider($other, $params); if (!empty($alt['success'])) { AAGL_Model_Registry::note_auto_switch('provider', $provider, $other); return $alt; } // 切替先も失敗した場合は、元のエラーを返す(原因が追いやすいため) return $result; } /** * プロバイダ名から該当の記事生成メソッドへ振り分ける * * @param string $provider claude|gemini * @param array $params 生成パラメータ * @return array 生成結果 */ private function generate_structured_article_by_provider($provider, $params) { if ('gemini' === $provider) { return $this->generate_structured_article_gemini($params); } return $this->generate_structured_article_claude($params); } /** * 構造化記事生成(Claude用) * * @param array $params 生成パラメータ * @return array 生成結果 */ public function generate_structured_article_claude($params) { try { // 共有ホスティング対応: 実行時間制限を延長 AAGL_Utilities::extend_execution_time(300); // APIキーチェック $api_key = AAGL_Utilities::get_api_key('claude_api_key'); if (empty($api_key)) { return array( 'success' => false, 'error' => 'Claude APIキーが設定されていません。API設定タブで設定してください。' ); } $prompt = $this->build_structured_article_prompt($params); $max_retries = defined('AAGL_MAX_RETRIES') ? AAGL_MAX_RETRIES : 2; $retry_prompt = $prompt; for ($attempt = 0; $attempt <= $max_retries; $attempt++) { $current_prompt = ( 0 === $attempt ) ? $prompt : $retry_prompt; $result = $this->call_claude_api($current_prompt); if (!$result['success']) { return $result; } // 記事内容を構造化して抽出 $article = $this->parse_structured_article($result['content'], 'claude'); // 品質バリデーション(テンプレートのmin_length設定を使用) $template_min_length = isset($params['template']['settings']['min_length']) ? (int) $params['template']['settings']['min_length'] : 800; $validation = self::validate_article_quality($article['content'], $params, $template_min_length); if ($validation['passed']) { if ( $attempt > 0 ) { AAGL_Utilities::log("Claude記事品質バリデーション: {$attempt}回リトライ後にパス", 'info'); } return array( 'success' => true, 'content' => $article['content'], 'structure' => $article['structure'], 'char_count' => mb_strlen($article['content']), 'provider' => 'claude', 'retry_count' => $attempt, ); } // 最終試行でも失敗した場合はエラーとして返す if ($attempt === $max_retries) { $content_preview = wp_strip_all_tags(mb_substr($article['content'] ?? '', 0, 100)); AAGL_Utilities::log("Claude記事品質バリデーション: {$max_retries}回リトライ後も不合格 - " . implode(', ', $validation['failures']) . " | レスポンス冒頭: " . $content_preview, 'warning'); return array( 'success' => false, 'error' => '記事品質バリデーション不合格: ' . implode(', ', $validation['failures']), 'content' => $article['content'], 'structure' => $article['structure'], 'char_count' => mb_strlen($article['content']), 'provider' => 'claude', 'retry_count' => $attempt, 'validation_warnings' => $validation['failures'], ); } // リトライ用プロンプト構築 $content_preview = wp_strip_all_tags(mb_substr($article['content'] ?? '', 0, 100)); AAGL_Utilities::log("Claude記事品質バリデーション不合格(試行" . ($attempt + 1) . "): " . implode(', ', $validation['failures']) . " | レスポンス冒頭: " . $content_preview, 'debug'); $retry_prompt = $this->build_retry_prompt($prompt, $validation['failures']); } } catch (Exception $e) { return array( 'success' => false, 'error' => 'Claude記事生成エラー: ' . $e->getMessage() ); } } /** * 構造化記事生成(Gemini用) * * @param array $params 生成パラメータ * @return array 生成結果 */ public function generate_structured_article_gemini($params) { try { // 共有ホスティング対応: 実行時間制限を延長 AAGL_Utilities::extend_execution_time(300); // APIキーチェック $api_key = AAGL_Utilities::get_api_key('gemini_api_key'); if (empty($api_key)) { return array( 'success' => false, 'error' => 'Gemini APIキーが設定されていません。API設定タブで設定してください。' ); } $prompt = $this->build_structured_article_prompt($params); $max_retries = defined('AAGL_MAX_RETRIES') ? AAGL_MAX_RETRIES : 2; $retry_prompt = $prompt; for ($attempt = 0; $attempt <= $max_retries; $attempt++) { $current_prompt = ( 0 === $attempt ) ? $prompt : $retry_prompt; $result = $this->call_gemini_api($current_prompt); if (!$result['success']) { return $result; } // 記事内容を構造化して抽出 $data = isset($result['data']) ? $result['data'] : $result; $article = $this->parse_structured_article($data, 'gemini'); // 品質バリデーション(テンプレートのmin_length設定を使用) $template_min_length = isset($params['template']['settings']['min_length']) ? (int) $params['template']['settings']['min_length'] : 800; $validation = self::validate_article_quality($article['content'], $params, $template_min_length); if ($validation['passed']) { if ( $attempt > 0 ) { AAGL_Utilities::log("Gemini記事品質バリデーション: {$attempt}回リトライ後にパス", 'info'); } return array( 'success' => true, 'content' => $article['content'], 'structure' => $article['structure'], 'char_count' => mb_strlen($article['content']), 'provider' => 'gemini', 'retry_count' => $attempt, ); } // 最終試行でも失敗した場合はエラーとして返す if ($attempt === $max_retries) { $content_preview = wp_strip_all_tags(mb_substr($article['content'] ?? '', 0, 100)); AAGL_Utilities::log("Gemini記事品質バリデーション: {$max_retries}回リトライ後も不合格 - " . implode(', ', $validation['failures']) . " | レスポンス冒頭: " . $content_preview, 'warning'); return array( 'success' => false, 'error' => '記事品質バリデーション不合格: ' . implode(', ', $validation['failures']), 'content' => $article['content'], 'structure' => $article['structure'], 'char_count' => mb_strlen($article['content']), 'provider' => 'gemini', 'retry_count' => $attempt, 'validation_warnings' => $validation['failures'], ); } // リトライ用プロンプト構築 $content_preview = wp_strip_all_tags(mb_substr($article['content'] ?? '', 0, 100)); AAGL_Utilities::log("Gemini記事品質バリデーション不合格(試行" . ($attempt + 1) . "): " . implode(', ', $validation['failures']) . " | レスポンス冒頭: " . $content_preview, 'debug'); $retry_prompt = $this->build_retry_prompt($prompt, $validation['failures']); } } catch (Exception $e) { return array( 'success' => false, 'error' => 'Gemini記事生成エラー: ' . $e->getMessage() ); } } /** * 構造化記事生成用プロンプト構築 * * @param array $params 生成パラメータ * @return string プロンプト */ private function build_structured_article_prompt($params) { $main_keyword = $params['main_keyword']; $sub_keywords = $params['sub_keywords']; $title = $params['title']; $reference_content = $params['reference_content'] ?? ''; $reference_urls = $params['reference_urls'] ?? array(); $template = $params['template'] ?? null; // テンプレートが必須 if (!$template || empty($template['prompt_template'])) { throw new Exception('テンプレートが指定されていません。記事生成にはテンプレート選択が必須です。'); } $sub_keywords_text = is_array($sub_keywords) ? implode('、', $sub_keywords) : $sub_keywords; // テンプレートプロンプトを使用 $prompt = $template['prompt_template']; // 参考URL数とソース一覧の生成 $reference_count = count($reference_urls); $reference_sources = ''; foreach ($reference_urls as $i => $ref) { $ref_title = is_array($ref) ? ($ref['title'] ?? '') : ''; $ref_url = is_array($ref) ? ($ref['url'] ?? $ref) : $ref; $reference_sources .= ($i + 1) . ". " . ($ref_title ? $ref_title . " - " : "") . $ref_url . "\n"; } // 参考指示の生成 $reference_instruction = !empty($reference_content) ? "以下の参考情報を基に記事を作成してください。参考情報の内容を踏まえつつ、独自の視点や付加価値を加えて記事を構成してください。" : "メインキーワードに関する一般的な知識を活用して記事を作成してください。"; // 変数置換(配列方式で統一) $replacements = array( '{{TITLE}}' => $title, '{{MAIN_KEYWORD}}' => $main_keyword, '{{SUB_KEYWORDS}}' => $sub_keywords_text, '{{REFERENCE_CONTENT}}' => $reference_content, '{{REFERENCE_SUMMARY}}' => $reference_content, '{{REFERENCE_COUNT}}' => $reference_count, '{{REFERENCE_SOURCES}}' => trim($reference_sources), '{{REFERENCE_INSTRUCTION}}' => $reference_instruction, ); // コンテキスト・ペルソナ変数の追加(テンプレート設定から取得) $settings = $template['settings'] ?? array(); $replacements['{{COMPANY_NAME}}'] = $settings['company_name'] ?? ''; $replacements['{{COMPANY_URL}}'] = $settings['company_url'] ?? ''; $replacements['{{TARGET_PERSONA}}'] = $settings['target_persona'] ?? ''; $replacements['{{CONTENT_TONE}}'] = $settings['content_tone'] ?? ''; $replacements['{{INDUSTRY}}'] = $settings['industry'] ?? ''; $replacements['{{WRITER_NAME}}'] = $settings['writer_name'] ?? ''; $replacements['{{WRITER_BACKGROUND}}'] = $settings['writer_background'] ?? ''; $replacements['{{WRITER_EXPERTISE}}'] = $settings['writer_expertise'] ?? ''; // 過去記事タイトル一覧({{EXISTING_TITLES}} 変数 + ニュースモード自動注入) $template_type = $settings['template_type'] ?? 'standard'; $existing_titles = $this->get_existing_article_titles_cached( $settings['category_id'] ?? null, $params['post_type'] ?? 'post' ); $existing_titles_text = ''; if (!empty($existing_titles)) { $existing_titles_text = "以下の既存記事と内容が重複しないよう、異なる切り口・視点で記事を作成してください:\n"; foreach ($existing_titles as $et) { $existing_titles_text .= "- {$et}\n"; } } $replacements['{{EXISTING_TITLES}}'] = $existing_titles_text; $prompt = str_replace(array_keys($replacements), array_values($replacements), $prompt); // 文字数要件をテンプレート設定から自動注入 $min_length = (int) ($settings['min_length'] ?? AAGL_DEFAULT_MIN_LENGTH); $max_length = (int) ($settings['max_length'] ?? AAGL_DEFAULT_MAX_LENGTH); $prompt .= "\n\n## 【必須:文字数要件】\n"; $prompt .= "記事の本文は必ず{$min_length}文字以上、{$max_length}文字以下で作成してください。\n"; $prompt .= "HTMLタグのみで出力し、```html等のコードフェンスは絶対に使わないでください。\n"; // AI感排除: 禁止・推奨表現の指示を末尾に追加 $prompt .= self::build_expression_control_instruction($template); // 構成多様化。同テンプレート×固定構成での連日生成による記事の金太郎飴化 // (o-dream.jp調査 2026-03-05)への対策。 $prompt .= $this->build_diversity_instruction($params['post_type'] ?? AAGL_Utilities::get_post_type_setting()); return $prompt; } /** * 直近の生成記事のH2構成を取得(構成多様化用) * * @param string $post_type 投稿タイプ * @param int $limit 取得記事数 * @return array [['title' => string, 'headings' => string[]], ...] */ private function get_recent_article_structures($post_type = 'post', $limit = 5) { $post_type = sanitize_key($post_type); if (!post_type_exists($post_type)) { $post_type = 'post'; } $posts = get_posts(array( 'post_type' => $post_type, 'post_status' => array('publish', 'draft'), 'posts_per_page' => $limit, 'orderby' => 'date', 'order' => 'DESC', 'no_found_rows' => true, 'update_post_term_cache' => false, 'meta_query' => array( array( 'key' => '_aagl_generated', 'value' => 'yes', ), ), )); $structures = array(); foreach ($posts as $post) { if (preg_match_all('/タグが含まれている場合は変換不要 if (preg_match('/
]/i', $content)) { return $content; } // マークダウンの見出し(## )が含まれていれば変換対象 if (!preg_match('/^#{1,3}\s/m', $content)) { // マークダウンでもHTMLでもない場合、段落タグで囲む if (!preg_match('/<[a-z][a-z0-9]*[\s>]/i', $content)) { $paragraphs = preg_split('/\n{2,}/', trim($content)); $html_parts = array(); foreach ($paragraphs as $para) { $para = trim($para); if (!empty($para)) { $html_parts[] = '
' . esc_html($para) . '
'; } } return implode("\n\n", $html_parts); } return $content; } AAGL_Utilities::log('マークダウン形式の応答を検出、HTML形式に変換します', 'info'); $lines = explode("\n", $content); $html_parts = array(); $current_paragraph = array(); foreach ($lines as $line) { $trimmed = trim($line); // 空行 → 段落の区切り if (empty($trimmed)) { if (!empty($current_paragraph)) { $html_parts[] = '' . implode(' ', $current_paragraph) . '
'; $current_paragraph = array(); } continue; } // H2: ## 見出し if (preg_match('/^##\s+(.+)$/', $trimmed, $m)) { if (!empty($current_paragraph)) { $html_parts[] = '' . implode(' ', $current_paragraph) . '
'; $current_paragraph = array(); } $html_parts[] = '' . implode(' ', $current_paragraph) . '
'; $current_paragraph = array(); } $html_parts[] = '' . implode(' ', $current_paragraph) . '
'; $current_paragraph = array(); } $html_parts[] = '' . implode(' ', $current_paragraph) . '
'; $current_paragraph = array(); } // 連続リスト項目をul/liにまとめる $last = end($html_parts); if ($last && preg_match('/<\/li>\s*$/', $last)) { // 前のリスト項目に追加 $html_parts[count($html_parts) - 1] .= "\n' . implode(' ', $current_paragraph) . '
'; } // ul の閉じタグを追加 $result = implode("\n\n", $html_parts); $result = preg_replace('/