'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 ($h2_count < 2) { $failures[] = "H2見出し不足({$h2_count}個 / 最低2個)"; } // 4. 段落数チェック(最低3つ) $p_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('/]*>(.*?)<\/h2>/is', $post->post_content, $m)) { $headings = array_values(array_filter(array_map(function ($h) { return mb_substr(trim(wp_strip_all_tags($h)), 0, 50); }, $m[1]))); if (!empty($headings)) { $structures[] = array( 'title' => mb_substr($post->post_title, 0, 60), 'headings' => array_slice($headings, 0, 8), ); } } } return $structures; } /** * 構成多様化指示を構築(金太郎飴対策) * * 直近記事のH2構成を提示して同型構成を避けさせ、切り口を累計生成数で * 決定論的にローテーションする。切り口は設定欄を設けず既定固定 * (設定欄を増やさない方針)。 * * @param string $post_type 投稿タイプ * @return string プロンプト追加指示 */ private function build_diversity_instruction($post_type) { $instruction = ''; $structures = $this->get_recent_article_structures($post_type, 5); if (!empty($structures)) { $instruction .= "\n\n## 【構成の差別化(重要)】\n"; $instruction .= "このサイトの直近の記事は以下の見出し構成でした。今回の記事は、これらと同じパターン(セクション数・見出しの言い回し・展開順序・導入と締めの型)を避け、異なる構成で作成してください:\n"; foreach ($structures as $i => $s) { $instruction .= ($i + 1) . ". 「{$s['title']}」: " . implode(' / ', $s['headings']) . "\n"; } } // 切り口ローテーション(累計生成数 mod 切り口数で決定論的に巡回) $angles = array( '初めて検討する人に向けた基礎からの解説', '具体的な場面・事例を起点にした展開', 'よくある疑問に順に答えていく構成', '手順・ステップを順に追っていく構成', '選び方・比較の観点を軸にした構成', 'ありがちな誤解・注意点の整理から入る構成', ); $total_generated = AAGL_Utilities::get_article_counts()['total']; $angle = $angles[ max(0, (int) $total_generated) % count($angles) ]; $instruction .= "\n## 【今回の切り口】\n"; $instruction .= "この記事は「{$angle}」を主軸にしてください。\n"; return $instruction; } /** * 構造化記事の解析 * * @param array $response_data APIレスポンスデータ * @param string $provider AIプロバイダー * @return array 解析結果 */ private function parse_structured_article($response_data, $provider) { try { $content = ''; if ('claude' === $provider) { // call_claude_apiは既にテキスト抽出済みの文字列を返すため if (is_string($response_data)) { $content = $response_data; } else { // 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']; } elseif (isset($response_data['content'])) { // generate_complete_articleから渡される場合の構造 $content = $response_data['content']; } elseif (is_string($response_data)) { // 直接文字列が渡される場合 $content = $response_data; } } if (empty($content)) { return array( 'content' => '記事生成に失敗しました。', 'structure' => array() ); } // マークダウン形式をHTML形式に変換(AIがHTML以外で返した場合の対応) $content = self::convert_markdown_to_html_if_needed($content); // 記事の構造を分析 $structure = $this->analyze_article_structure($content); return array( 'content' => $content, 'structure' => $structure ); } catch (Exception $e) { return array( 'content' => '記事解析中にエラーが発生しました: ' . $e->getMessage(), 'structure' => array() ); } } /** * マークダウン形式のコンテンツをHTML形式に変換する(必要な場合のみ) * * AIがHTMLではなくマークダウンで応答した場合に対応する。 * 既にHTMLタグが含まれている場合はそのまま返す。 * * @param string $content コンテンツ * @return string HTML形式のコンテンツ */ public static function convert_markdown_to_html_if_needed($content) { // コードフェンス(```html ... ``` や ``` ... ```)を除去 $content = preg_replace('/^```(?:html)?\s*\n?/i', '', trim($content)); $content = preg_replace('/\n?```\s*$/', '', $content); $content = trim($content); // 既にHTMLの

や

タグが含まれている場合は変換不要 if (preg_match('/]/i', $content) && 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[] = '

' . esc_html(trim($m[1])) . '

'; continue; } // H3: ### 見出し if (preg_match('/^###\s+(.+)$/', $trimmed, $m)) { if (!empty($current_paragraph)) { $html_parts[] = '

' . implode(' ', $current_paragraph) . '

'; $current_paragraph = array(); } $html_parts[] = '

' . esc_html(trim($m[1])) . '

'; continue; } // H1: # 見出し → H2に変換(記事内にH1は不要) if (preg_match('/^#\s+(.+)$/', $trimmed, $m)) { if (!empty($current_paragraph)) { $html_parts[] = '

' . implode(' ', $current_paragraph) . '

'; $current_paragraph = array(); } $html_parts[] = '

' . esc_html(trim($m[1])) . '

'; continue; } // リスト項目: - item or * item if (preg_match('/^[\-\*]\s+(.+)$/', $trimmed, $m)) { if (!empty($current_paragraph)) { $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
  • " . esc_html(trim($m[1])) . '
  • '; } else { $html_parts[] = '
      ' . "\n" . '
    • ' . esc_html(trim($m[1])) . '
    • '; } continue; } // 先にエスケープしてから太字変換(のみ許可) $trimmed = esc_html($trimmed); $trimmed = preg_replace('/\*\*(.+?)\*\*/', '$1', $trimmed); // 通常テキスト → 段落に追加 $current_paragraph[] = $trimmed; } // 残りの段落 if (!empty($current_paragraph)) { $html_parts[] = '

      ' . implode(' ', $current_paragraph) . '

      '; } // ul の閉じタグを追加 $result = implode("\n\n", $html_parts); $result = preg_replace('/
    • ([^<]*)<\/li>\s*(?!
    • |<\/ul>)/s', '
    • $1
    • ' . "\n" . '
    ', $result); // 未閉じulがあれば閉じる $open_uls = substr_count($result, '
      '); $close_uls = substr_count($result, '
    '); if ($open_uls > $close_uls) { $result .= str_repeat("\n", $open_uls - $close_uls); } return $result; } /** * 記事構造の分析 * * @param string $content 記事内容 * @return array 構造情報 */ private function analyze_article_structure($content) { $structure = array( 'title' => '', 'introduction' => '', 'sections' => array(), 'conclusion' => '', 'references' => array() ); $lines = explode("\n", $content); $current_section = ''; $current_content = ''; foreach ($lines as $line) { $line = trim($line); if (preg_match('/^# (.+)/', $line, $matches)) { // H1 タイトル $structure['title'] = $matches[1]; } elseif (preg_match('/^## (.+)/', $line, $matches)) { // H2 セクション if (!empty($current_section)) { // 前のセクションを保存 if ('導入文' === $current_section) { $structure['introduction'] = $current_content; } elseif ('まとめ' === $current_section) { $structure['conclusion'] = $current_content; } else { $structure['sections'][] = array( 'title' => $current_section, 'content' => $current_content ); } } $current_section = $matches[1]; $current_content = ''; } elseif (!empty($line)) { // コンテンツ行 $current_content .= $line . "\n"; } } // 最後のセクションを保存 if (!empty($current_section)) { if ('導入文' === $current_section) { $structure['introduction'] = $current_content; } elseif ('まとめ' === $current_section) { $structure['conclusion'] = $current_content; } else { $structure['sections'][] = array( 'title' => $current_section, 'content' => $current_content ); } } return $structure; } /** * 暗号化されたAPIキーを取得・復号化 * * @param string $key_name APIキー名 * @return string 復号化されたAPIキー */ private function get_api_key($key_name) { return AAGL_Utilities::get_api_key($key_name); } /** * 選択されたClaudeモデルを取得 */ private function get_claude_model() { if (class_exists('AAGL_Model_Registry')) { return AAGL_Model_Registry::get_configured_model('claude'); } $model = AAGL_Utilities::get_setting('ai_settings.claude_model', AAGL_DEFAULT_CLAUDE_MODEL); return array_key_exists($model, self::CLAUDE_MODELS) ? $model : AAGL_DEFAULT_CLAUDE_MODEL; } /** * 選択されたGeminiモデルを取得 */ private function get_gemini_model() { if (class_exists('AAGL_Model_Registry')) { return AAGL_Model_Registry::get_configured_model('gemini'); } $model = AAGL_Utilities::get_setting('ai_settings.gemini_model', AAGL_DEFAULT_GEMINI_MODEL); return array_key_exists($model, self::GEMINI_MODELS) ? $model : AAGL_DEFAULT_GEMINI_MODEL; } /** * GeminiのAPI URLを動的に生成 */ private function get_gemini_api_url() { $model = $this->get_gemini_model(); return $this->gemini_api_url_base . $model . ':generateContent'; } /** * Ajax: サブキーワード生成 */ public function ajax_generate_subkeywords() { // セキュリティチェック if (!check_ajax_referer('aagl_ajax_nonce', 'nonce', false)) { wp_send_json_error('セキュリティチェックに失敗しました。'); } if (!current_user_can('manage_options')) { wp_send_json_error('権限が不十分です。'); } $main_keyword = sanitize_text_field(wp_unslash($_POST['main_keyword'] ?? '')); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified above $count = intval(wp_unslash($_POST['count'] ?? 10)); // phpcs:ignore WordPress.Security.NonceVerification.Missing $category = sanitize_text_field(wp_unslash($_POST['category'] ?? '')); // phpcs:ignore WordPress.Security.NonceVerification.Missing $provider = sanitize_text_field(wp_unslash($_POST['provider'] ?? 'claude')); // phpcs:ignore WordPress.Security.NonceVerification.Missing if (empty($main_keyword)) { wp_send_json_error('メインキーワードが指定されていません。'); } // AI生成実行 if ('claude' === $provider) { $result = $this->generate_subkeywords_claude($main_keyword, $count, $category); } elseif ('gemini' === $provider) { $result = $this->generate_subkeywords_gemini($main_keyword, $count, $category); } else { wp_send_json_error('無効なAPIプロバイダーです。'); } if ($result['success']) { wp_send_json_success($result); } else { wp_send_json_error($result['error']); } } /** * 生成した記事をWordPress投稿として保存(新要件対応) * * @param array $article_data 記事データ * @param array $params 生成パラメータ * @return array 保存結果 */ public function save_generated_article($article_data, $params) { // 本文リンクのドメインフィルタ(プロンプトインジェクション対策) // 許可: 自サイト・参考URLのドメイン・公的ドメイン。それ以外のはテキスト化。 // ※末尾の参考URL一覧は参考URLドメインなので残る。内部リンクはこの後段で挿入される。 $allowed_link_hosts = array(); foreach ((array) ($params['reference_urls'] ?? array()) as $ref) { $ref_url = is_array($ref) ? ($ref['url'] ?? '') : $ref; $ref_host = $ref_url ? wp_parse_url($ref_url, PHP_URL_HOST) : ''; if (!empty($ref_host)) { $allowed_link_hosts[] = $ref_host; } } $article_data['content'] = AAGL_Utilities::filter_body_links($article_data['content'] ?? '', $allowed_link_hosts); // メタディスクリプション自動生成 $meta_description = self::generate_meta_description($article_data['content'] ?? ''); $post_excerpt = self::generate_meta_description($article_data['content'] ?? '', 160); try { // 投稿者の決定: params指定 > ログインユーザー > デフォルト管理者 $post_author = 1; if (!empty($params['author_id'])) { $post_author = intval($params['author_id']); } elseif (function_exists('get_current_user_id') && get_current_user_id() > 0) { $post_author = get_current_user_id(); } // カテゴリの決定: params指定(テンプレート設定含む) > デフォルト未分類 // 標準カテゴリだけでなくカスタムタクソノミーのタームにも対応 $category_id = 1; $category_taxonomy = 'category'; if (!empty($params['category_id'])) { $template_category_id = intval($params['category_id']); if ($template_category_id > 0) { // タームが属するタクソノミーを自動検出し、公開性を検証 $term_obj = get_term($template_category_id); if ($term_obj && !is_wp_error($term_obj)) { $tax_obj = get_taxonomy($term_obj->taxonomy); if ($tax_obj && $tax_obj->public) { $category_id = $template_category_id; $category_taxonomy = $term_obj->taxonomy; } } } } // 投稿データの準備 $aagl_post_type = AAGL_Utilities::get_post_type_setting(); $post_data = array( 'post_title' => sanitize_text_field($params['title'] ?? 'AI生成記事'), 'post_content' => wp_kses_post($article_data['content']), 'post_excerpt' => $post_excerpt, 'post_status' => sanitize_text_field($params['post_status'] ?? 'draft'), 'post_type' => $aagl_post_type, 'post_author' => $post_author, ); // 標準カテゴリはwp_insert_postで直接設定、カスタムタクソノミーは後処理 if ($category_taxonomy === 'category') { $post_data['post_category'] = array($category_id); } // 固定ページの親ページ設定(カテゴリ種別に関係なく適用) if ($aagl_post_type === 'page') { $parent_page_id = AAGL_Utilities::get_parent_page_setting(); if ($parent_page_id > 0) { $post_data['post_parent'] = $parent_page_id; } } // 投稿作成 $post_id = wp_insert_post($post_data, true); // カスタムタクソノミーの場合は投稿作成後にタームを設定 if (!is_wp_error($post_id) && $category_taxonomy !== 'category') { $term_result = wp_set_object_terms($post_id, array($category_id), $category_taxonomy); if (is_wp_error($term_result)) { error_log('AAG: Failed to set term ' . $category_id . ' (' . $category_taxonomy . ') for post ' . $post_id . ': ' . $term_result->get_error_message()); } } if (is_wp_error($post_id)) { return array( 'success' => false, 'error' => '投稿の作成に失敗しました: ' . $post_id->get_error_message() ); } // カスタムメタデータの保存 $generated_by = sanitize_text_field($params['generated_by'] ?? 'manual'); $meta_data = array( '_aagl_generated' => 'yes', '_aagl_generated_by' => $generated_by, '_aagl_main_keyword' => sanitize_text_field($params['main_keyword']), '_aagl_sub_keywords' => wp_json_encode(array_map('sanitize_text_field', $params['sub_keywords'] ?? array())), '_aagl_generation_date' => current_time('mysql'), '_aagl_ai_model' => sanitize_text_field($article_data['provider'] ?? 'unknown'), '_aagl_title_candidates' => wp_json_encode($params['title_candidates'] ?? array()), '_aagl_reference_urls' => wp_json_encode($params['reference_urls'] ?? array()), '_aagl_search_queries' => wp_json_encode($params['search_queries'] ?? array()), '_aagl_content_length' => intval($article_data['char_count'] ?? mb_strlen($article_data['content'] ?? '')), '_aagl_meta_description' => $meta_description, ); if (!empty($params['template_id'])) { $meta_data['_aagl_template_id'] = sanitize_text_field($params['template_id']); } if (!empty($params['keyword_data'])) { $meta_data['_aagl_keyword_data'] = $params['keyword_data']; } if (isset($params['source_count'])) { $meta_data['_aagl_source_count'] = intval($params['source_count']); } if (!empty($params['no_source_behavior'])) { $meta_data['_aagl_no_source_behavior'] = sanitize_text_field($params['no_source_behavior']); } if (!empty($params['grounding_metadata'])) { $meta_data['_aagl_grounding_metadata'] = wp_json_encode($params['grounding_metadata']); } foreach ($meta_data as $meta_key => $meta_value) { update_post_meta($post_id, $meta_key, $meta_value); } // アイキャッチ画像の設定 $this->maybe_set_featured_image($post_id, $params['main_keyword'], $params['template_id'] ?? ''); // 内部リンク自動挿入 $related_section = self::generate_related_posts_section( $params['main_keyword'] ?? '', $post_id ); if (!empty($related_section)) { $updated_content = $article_data['content'] . $related_section; wp_update_post(array( 'ID' => $post_id, 'post_content' => wp_kses_post($updated_content), )); } // 生成履歴に追加(スケジュール生成など) if (!empty($params['log_history'])) { $history = get_option('aagl_generation_history', array()); $history[] = array( 'post_id' => $post_id, 'title' => get_the_title($post_id), 'created_at' => current_time('mysql'), 'status' => get_post_status($post_id), 'generated_by' => $generated_by, 'template_id' => $params['template_id'] ?? '', 'main_keyword' => $params['main_keyword'] ?? '', 'ai_provider' => $params['provider'] ?? ($article_data['provider'] ?? ''), 'reference_urls_count' => count($params['reference_urls'] ?? array()), 'content_length' => mb_strlen($article_data['content'] ?? ''), ); if (count($history) > 100) { $history = array_slice($history, -100); } update_option('aagl_generation_history', $history); } // 成功レスポンス return array( 'success' => true, 'post_id' => $post_id, 'post_url' => get_permalink($post_id), 'edit_url' => get_edit_post_link($post_id, 'raw'), 'preview_url' => get_preview_post_link($post_id), 'post_status' => get_post_status($post_id), 'message' => sprintf( '記事「%s」が%sで保存されました。', get_the_title($post_id), 'publish' === get_post_status($post_id) ? '公開' : '下書き' ) ); } catch (Exception $e) { return array( 'success' => false, 'error' => '記事保存中にエラーが発生しました: ' . $e->getMessage() ); } } /** * アイキャッチ画像の設定(必要に応じて) * * @param int $post_id 投稿ID * @param string $keyword キーワード */ private function maybe_set_featured_image($post_id, $keyword, $template_id = '') { // グローバル設定でfeatured_imageが有効な場合のみ実行 $settings = get_option('aagl_settings', array()); $system_settings = $settings['system_settings'] ?? array(); $enable_featured_image = !empty($system_settings['enable_featured_image']); if (!$enable_featured_image) { return; } // テンプレートにデフォルト画像が設定されていればそれを使用 if (!empty($template_id)) { $wp_native = AAGL_WordPress_Native_System::get_instance(); $template = $wp_native->get_template($template_id); if ($template) { $default_image_id = intval($template['settings']['default_featured_image_id'] ?? 0); } if (!empty($default_image_id) && wp_attachment_is_image($default_image_id)) { set_post_thumbnail($post_id, $default_image_id); AAGL_Utilities::log('Set default featured image (ID: ' . $default_image_id . ') for post ' . $post_id); return; } } } /** * Ajax: API接続テスト */ /** * 管理者パスワード検証(AJAX) */ public function ajax_verify_admin_pass() { if ( ! check_ajax_referer( 'aagl_ajax_nonce', 'nonce', false ) ) { wp_send_json_error( 'セキュリティチェックに失敗しました。' ); } if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( '権限がありません。' ); } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- パスワードは照合にのみ使い、保存・出力しない。文字を削ると照合できなくなるためサニタイズしない $pass = isset( $_POST['admin_pass'] ) ? wp_unslash( $_POST['admin_pass'] ) : ''; if ( AAGL_Utilities::verify_admin_pass( $pass ) ) { // セッショントークンを発行(30分有効)— 設定画面全体のアンロック用 $token = wp_generate_password( 32, false ); set_transient( 'aagl_settings_unlock_' . get_current_user_id(), $token, 1800 ); wp_send_json_success( array( 'message' => '認証に成功しました。', 'reload' => true ) ); } else { wp_send_json_error( 'パスワードが正しくありません。' ); } } public function ajax_test_api_connection() { // セキュリティチェック if (!check_ajax_referer('aagl_ajax_nonce', 'nonce', false)) { wp_send_json_error('セキュリティチェックに失敗しました。'); } if (!current_user_can('manage_options')) { wp_send_json_error('権限が不十分です。'); } $provider = sanitize_text_field(wp_unslash($_POST['provider'] ?? '')); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified above $valid_providers = array('claude', 'gemini'); if (empty($provider) || !in_array($provider, $valid_providers, true)) { wp_send_json_error('無効なAPIプロバイダーです。'); } $result = $this->test_api_connection($provider); if ($result['success']) { wp_send_json_success($result); } else { wp_send_json_error($result); } } /** * Claude API呼び出し */ private function call_claude_api($prompt, $max_tokens = 18000, $model = null, $allow_model_fallback = true) { if ( null === $model ) { // 設定値をホワイトリスト検証経由で解決(廃止モデルIDの送信防止) $model = $this->get_claude_model(); } $api_key = AAGL_Utilities::get_api_key('claude_api_key'); if (empty($api_key)) { return array( 'success' => false, 'error' => 'Claude APIキーが設定されていません', 'error_code' => 'no_api_key' ); } try { $url = 'https://api.anthropic.com/v1/messages'; $headers = array( 'Content-Type' => 'application/json', 'x-api-key' => $api_key, 'anthropic-version' => '2023-06-01' ); $body = array( 'model' => $model, 'max_tokens' => $max_tokens, '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($model)) { $body['temperature'] = 0.7; } // 5系は既定の適応的思考が max_tokens を食い、text ブロックが返らないことがあるため思考を無効化 $thinking_param = AAGL_Utilities::claude_thinking_param($model); if (null !== $thinking_param) { $body['thinking'] = $thinking_param; } $response = wp_remote_post($url, array( 'headers' => $headers, 'body' => wp_json_encode($body), 'timeout' => defined('AAGL_TIMEOUT_AI_API') ? AAGL_TIMEOUT_AI_API : 120 )); if (is_wp_error($response)) { return array( 'success' => false, 'error' => 'API通信エラー: ' . $response->get_error_message() ); } $response_body = wp_remote_retrieve_body($response); $response_code = wp_remote_retrieve_response_code($response); $data = json_decode($response_body, true); // Sonnet 5 以降は thinking ブロックが先頭に来るため content[0] ではなく text ブロックを探す $response_text = (200 === $response_code) ? AAGL_Utilities::extract_claude_text($data) : ''; if ('' !== $response_text) { // モデルエラーフラグ解除(正常応答が確認できたため) AAGL_Utilities::clear_model_error('claude'); return array( 'success' => true, 'content' => $response_text ); } else { // 200 なのに text が無い(思考のみで max_tokens 到達など)ケースを「Unknown error」で潰さず形を残す $error_message = AAGL_Utilities::describe_claude_response_shape($data); if (isset($data['error']['message'])) { $error_message = $data['error']['message']; } elseif (isset($data['error'])) { $error_message = wp_json_encode($data['error']); } elseif (200 !== $response_code) { $error_message = 'HTTP ' . $response_code . ': ' . $response_body; } // モデル廃止検出: 404は管理画面通知で表面化(全フロー一斉停止のサイレント化防止) if (404 === $response_code) { AAGL_Utilities::flag_model_error('claude', $model, $error_message); // 廃止されたモデルを指したままでは以降すべて失敗するため、 // 代替モデルで1度だけ再試行する(無限再帰は $allow_model_fallback で防ぐ) if ($allow_model_fallback && class_exists('AAGL_Model_Registry')) { $fallback_model = AAGL_Model_Registry::get_fallback_model('claude', $model); if (!empty($fallback_model)) { $retry = $this->call_claude_api($prompt, $max_tokens, $fallback_model, false); if (!empty($retry['success'])) { AAGL_Model_Registry::note_auto_switch('model', $model, $fallback_model); return $retry; } } } return array( 'success' => false, 'error' => 'APIレスポンスエラー: ' . $error_message, 'error_code' => 'model_unavailable' ); } return array( 'success' => false, 'error' => 'APIレスポンスエラー: ' . $error_message ); } } catch (Exception $e) { return array( 'success' => false, 'error' => 'API呼び出し例外: ' . $e->getMessage() ); } } /** * Gemini API呼び出し */ private function call_gemini_api($prompt, $max_tokens = 18000, $model = null, $allow_model_fallback = true) { if ( null === $model ) { // 設定値をホワイトリスト検証経由で解決(廃止モデルIDの送信防止) $model = $this->get_gemini_model(); } $api_key = AAGL_Utilities::get_api_key('gemini_api_key'); if (empty($api_key)) { return array( 'success' => false, 'error' => 'Gemini APIキーが設定されていません', 'error_code' => 'no_api_key' ); } try { $url = 'https://generativelanguage.googleapis.com/v1beta/models/' . $model . ':generateContent'; $headers = array( 'Content-Type' => 'application/json', 'x-goog-api-key' => $api_key, ); // 修正: maxOutputTokensを増加してThinking機能のトークン消費に対応 $actual_max_tokens = max($max_tokens, 3000); // 最小3000トークンを確保 $body = array( 'contents' => array( array( 'parts' => array( array( 'text' => $prompt ) ) ) ), 'generationConfig' => array( 'temperature' => 0.7, 'maxOutputTokens' => $actual_max_tokens, 'topK' => 40, 'topP' => 0.95, 'candidateCount' => 1 ), 'safetySettings' => array( array( 'category' => 'HARM_CATEGORY_HARASSMENT', 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE' ), array( 'category' => 'HARM_CATEGORY_HATE_SPEECH', 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE' ), array( 'category' => 'HARM_CATEGORY_SEXUALLY_EXPLICIT', 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE' ), array( 'category' => 'HARM_CATEGORY_DANGEROUS_CONTENT', 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE' ) ) ); $response = wp_remote_post($url, array( 'headers' => $headers, 'body' => wp_json_encode($body), 'timeout' => defined('AAGL_TIMEOUT_AI_API') ? AAGL_TIMEOUT_AI_API : 120 )); if (is_wp_error($response)) { return array( 'success' => false, 'error' => 'API通信エラー: ' . $response->get_error_message() ); } $response_body = wp_remote_retrieve_body($response); $response_code = wp_remote_retrieve_response_code($response); $data = json_decode($response_body, true); if (200 !== $response_code) { AAGL_Utilities::log('AAG Gemini API Error - HTTP ' . $response_code . ': ' . $response_body); // モデル廃止検出: 404は管理画面通知で表面化 if (404 === $response_code) { AAGL_Utilities::flag_model_error('gemini', $model, $response_body); // 廃止されたモデルを指したままでは以降すべて失敗するため、 // 代替モデルで1度だけ再試行する(無限再帰は $allow_model_fallback で防ぐ) if ($allow_model_fallback && class_exists('AAGL_Model_Registry')) { $fallback_model = AAGL_Model_Registry::get_fallback_model('gemini', $model); if (!empty($fallback_model)) { $retry = $this->call_gemini_api($prompt, $max_tokens, $fallback_model, false); if (!empty($retry['success'])) { AAGL_Model_Registry::note_auto_switch('model', $model, $fallback_model); return $retry; } } } return array( 'success' => false, 'error' => 'APIレスポンスエラー: HTTP ' . $response_code . ' - ' . $response_body, 'error_code' => 'model_unavailable' ); } return array( 'success' => false, 'error' => 'APIレスポンスエラー: HTTP ' . $response_code . ' - ' . $response_body ); } // シンプルにレスポンスからテキストを取得 if (isset($data['candidates'][0]['content']['parts'][0]['text'])) { // 通常のレスポンス構造 $generated_text = $data['candidates'][0]['content']['parts'][0]['text']; if (!empty(trim($generated_text))) { AAGL_Utilities::clear_model_error('gemini'); return array( 'success' => true, 'content' => $generated_text ); } } // エラーがある場合 if (isset($data['error'])) { $error_message = isset($data['error']['message']) ? $data['error']['message'] : wp_json_encode($data['error']); return array( 'success' => false, 'error' => 'APIエラー: ' . $error_message ); } // finishReasonがMAX_TOKENSの場合の特別処理 if (isset($data['candidates'][0]['finishReason']) && 'MAX_TOKENS' === $data['candidates'][0]['finishReason']) { AAGL_Utilities::log('AAG Gemini API Warning - MAX_TOKENS reached. Thoughts consumed too many tokens.', 'warning'); return array( 'success' => false, 'error' => 'Gemini Thinking機能がトークンを使い果たしました。maxOutputTokensを増やすか、別のモデルを使用してください。' ); } // それ以外の場合は、レスポンス全体を返して確認 return array( 'success' => false, 'error' => 'レスポンス構造が予期しない形式です。デバッグ情報: ' . wp_json_encode($data) ); } catch (Exception $e) { return array( 'success' => false, 'error' => 'API呼び出し例外: ' . $e->getMessage() ); } } /** * AI記事タイトル生成(AI生成のみ、フォールバック削除) */ public function generate_article_title($main_keyword, $sub_keywords, $provider = 'claude', $options = array()) { try { // main_keywordが配列の場合はkeywordフィールドを取得、それ以外は文字列として扱う $main_keyword_text = is_array($main_keyword) ? $main_keyword['keyword'] : (string) $main_keyword; $sub_keyword_texts = is_array($sub_keywords) ? array_column($sub_keywords, 'sub_keyword') : array(); $sub_keywords_str = implode('、', array_slice($sub_keyword_texts, 0, 3)); // 最大3個まで // ステップ1のAI設定を取得 $ai_settings = AAGL_Utilities::get_array_value(get_option('aagl_settings', array()), 'ai_settings', array()); // 「自動」設定を含めて1箇所で解決する(従来は auto がここで例外になっていた) $preferred_provider = AAGL_Utilities::determine_provider($ai_settings['preferred_ai'] ?? $provider); // APIキーを復号化して取得 $claude_api_key = AAGL_Utilities::get_api_key('claude_api_key'); $gemini_api_key = AAGL_Utilities::get_api_key('gemini_api_key'); // 優先AIに応じてAPIキーをチェック if ('claude' === $preferred_provider && empty($claude_api_key)) { return array( 'success' => false, 'error' => 'Claude APIキーが設定されていません。ステップ1でAPIキーを設定してください。' ); } elseif ('gemini' === $preferred_provider && empty($gemini_api_key)) { return array( 'success' => false, 'error' => 'Gemini APIキーが設定されていません。ステップ1でAPIキーを設定してください。' ); } // AI を使ったタイトル生成プロンプト $prompt = "あなたはSEOの専門家であり、コピーライティングのプロです。以下の**メインキーワード**と**サブキーワード**に基づいて、以下のステップで**35文字前後**のSEOに強く、クリックしたくなる魅力的な記事タイトルを3つ提案してください。\n\n"; $prompt .= "**■ キーワード**\n"; $prompt .= "メインキーワード: {$main_keyword_text}\n"; $prompt .= "サブキーワード: {$sub_keywords_str}\n\n"; $prompt .= "**■ ステップ1:キーワードから読者の悩みを推測**\n"; $prompt .= "提供された**メインキーワード**と**サブキーワード**から、読者がどのような情報を求めているか、どのような悩みを抱えているかを深く考察してください。\n\n"; $prompt .= "**■ ステップ2:タイトルの作成**\n"; $prompt .= "* **メインキーワード**と**サブキーワード**を自然に含めてください。\n"; $prompt .= "* **35文字前後**に収まるように簡潔にまとめてください。\n"; $prompt .= "* ステップ1で推測した読者の悩みに寄り添い、その**解決策を提示するような期待感**を与えてください。\n"; $prompt .= "* 数字や記号(例:【】、!)を効果的に使用して、クリックしたくなる**魅力**と**独自性**を加えてください。\n\n"; // 過去記事タイトル重複回避(ニュースモードでは必須、標準モードではオプション) $existing_titles = $this->get_existing_article_titles_cached( $options['category_id'] ?? null, $options['post_type'] ?? 'post' ); if (!empty($existing_titles)) { $prompt .= "**■ 重複回避**\n"; $prompt .= "以下の既存記事タイトルと内容が重複しないよう、異なる切り口で提案してください:\n"; foreach (array_slice($existing_titles, 0, 30) as $existing_title) { $prompt .= "- {$existing_title}\n"; } $prompt .= "\n"; } // テンプレートの禁止表現をタイトル生成にも適用 // (タイトルは本文と別経路で生成されるためテンプレートのルールが届かない問題への対処) $forbidden_raw = trim((string) ($options['forbidden_expressions'] ?? '')); if ('' !== $forbidden_raw) { $forbidden_items = array_filter(array_map('trim', explode("\n", str_replace("\r\n", "\n", $forbidden_raw)))); if (!empty($forbidden_items)) { $prompt .= "**■ タイトルの禁止表現(上記すべての指示より優先)**\n"; $prompt .= "以下の表現・記号はタイトルに絶対に使用しないでください:\n"; foreach ($forbidden_items as $item) { $prompt .= "- 「{$item}」\n"; } $prompt .= "\n"; } } $prompt .= "**■ 出力形式**\n"; $prompt .= "最終的なアウトプットは、タイトル案3つのみにしてください。それ以外の補足説明や思考プロセスは一切表示しないでください。\n\n"; $prompt .= "JSON形式で出力してください:\n"; $prompt .= '{"titles": ["タイトル1", "タイトル2", "タイトル3"]}'; // 設定されたモデルを使用してAPI呼び出し(ホワイトリスト検証経由で解決) if ('claude' === $preferred_provider) { $result = $this->call_claude_api($prompt, 1000, $this->get_claude_model()); } elseif ('gemini' === $preferred_provider) { $result = $this->call_gemini_api($prompt, 1000, $this->get_gemini_model()); } else { return array( 'success' => false, 'error' => '無効なAIプロバイダーが指定されました。ステップ1でAI設定を確認してください。' ); } if (!$result['success']) { return array( 'success' => false, 'error' => 'AI APIの呼び出しに失敗しました: ' . ($result['error'] ?? 'Unknown error') ); } // JSON解析を試行 $content = $result['content']; // JSONブロックをコードフェンス形式またはオブジェクト形式から抽出 if (preg_match('/```json\s*(.*?)\s*```/s', $content, $matches)) { $json_content = $matches[1]; } elseif (preg_match('/\{.*?\}/s', $content, $matches)) { $json_content = $matches[0]; } else { return array( 'success' => false, 'error' => 'AIからの応答がJSON形式ではありません。' ); } $data = json_decode($json_content, true); if (JSON_ERROR_NONE !== json_last_error()) { return array( 'success' => false, 'error' => 'AIからの応答のJSON解析に失敗しました: ' . json_last_error_msg() ); } if (!isset($data['titles']) || !is_array($data['titles']) || count($data['titles']) === 0) { return array( 'success' => false, 'error' => 'AIからの応答に有効なタイトルが含まれていません。' ); } $selected_title = $data['titles'][0]; // 最初のタイトルを選択 return array( 'success' => true, 'title' => $selected_title, 'candidates' => $data['titles'] ); } catch (Exception $e) { return array( 'success' => false, 'error' => 'タイトル生成エラー: ' . $e->getMessage() ); } } /** * 過去の記事タイトルを取得(キャッシュ付きラッパー) * 同一リクエスト内で複数回呼ばれてもDBクエリは1回のみ。 * * @param int|null $category_id カテゴリID * @param string $post_type 投稿タイプ * @param int $limit 取得件数上限 * @return array タイトル文字列の配列 */ private function get_existing_article_titles_cached($category_id = null, $post_type = 'post', $limit = 30) { static $cache = array(); $cache_key = ($category_id ?? '0') . '_' . $post_type . '_' . $limit; if (isset($cache[$cache_key])) { return $cache[$cache_key]; } $cache[$cache_key] = $this->get_existing_article_titles($category_id, $post_type, $limit); return $cache[$cache_key]; } /** * 過去の生成記事タイトルを取得(重複回避用) * * @param int|null $category_id カテゴリID(nullの場合は全カテゴリ) * @param string $post_type 投稿タイプ * @param int $limit 取得件数上限 * @return array タイトル文字列の配列 */ private function get_existing_article_titles($category_id = null, $post_type = 'post', $limit = 30) { // post_type 検証(WordPressに登録済みの投稿タイプのみ許可) $post_type = sanitize_key($post_type); if (!post_type_exists($post_type)) { $post_type = 'post'; } $args = array( 'post_type' => $post_type, 'post_status' => 'publish', 'posts_per_page' => $limit, 'orderby' => 'date', 'order' => 'DESC', 'no_found_rows' => true, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, ); if ($category_id && (int) $category_id > 0) { // タームのタクソノミーを自動検出して適切なクエリを構築 $term_obj = get_term((int) $category_id); $tax_obj = $term_obj && !is_wp_error($term_obj) ? get_taxonomy($term_obj->taxonomy) : null; if ($tax_obj && $tax_obj->public && $term_obj->taxonomy !== 'category') { // カスタムタクソノミーの場合はtax_queryを使用 $args['tax_query'] = array( array( 'taxonomy' => $term_obj->taxonomy, 'field' => 'term_id', 'terms' => (int) $category_id, ), ); } else { // 標準カテゴリの場合は従来通り $args['cat'] = (int) $category_id; } } $posts = get_posts($args); return array_map(function ($post) { return mb_substr($post->post_title, 0, 100); }, $posts); } } // インスタンス初期化 AAGL_AI_Integration::get_instance();