intval($system_settings['max_articles_per_day'] ?? 10), 'max_articles_per_month' => intval($system_settings['max_articles_per_month'] ?? 0), ); } /** * AI設定取得 */ public static function get_ai_settings() { $settings = get_option('aagl_settings', array()); $ai_settings = $settings['ai_settings'] ?? array(); return array( 'preferred_ai' => sanitize_key($ai_settings['preferred_ai'] ?? 'claude'), 'temperature' => floatval($ai_settings['temperature'] ?? 0.7), 'claude_model' => sanitize_text_field($ai_settings['claude_model'] ?? AAGL_DEFAULT_CLAUDE_MODEL), 'gemini_model' => sanitize_text_field($ai_settings['gemini_model'] ?? AAGL_DEFAULT_GEMINI_MODEL) ); } /** * 使用するAIプロバイダを決定する * * 設定画面には claude / gemini / 自動(auto) の3択があるが、記事生成の各経路が * 個別に分岐を書いていたため「自動」を選ぶと生成が例外で止まっていた。 * 判定をこの1箇所に集約し、全経路から同じ結果が返るようにする。 * * @param string|null $requested 呼び出し側が指定するプロバイダ(省略時は設定値) * @return string 'claude' または 'gemini' */ public static function determine_provider($requested = null) { $provider = is_string($requested) && '' !== $requested ? sanitize_key($requested) : self::get_setting('ai_settings.preferred_ai', 'claude'); $provider = sanitize_key((string) $provider); if ('auto' === $provider) { // 自動: APIキーが設定されている方を使う(Claude優先) if (!empty(self::get_api_key('claude_api_key'))) { return 'claude'; } if (!empty(self::get_api_key('gemini_api_key'))) { return 'gemini'; } return 'claude'; // どちらも未設定 → 後段のキーチェックで明示的に失敗させる } // 想定外の値(DB改竄・旧設定の残骸)は既定へ倒す return in_array($provider, array('claude', 'gemini'), true) ? $provider : 'claude'; } /** * 管理画面通知追加 */ public static function add_admin_notice($message, $type = 'info') { $notices = get_transient('aagl_admin_notices') ? get_transient('aagl_admin_notices') : array(); $notices[] = array( 'message' => $message, 'type' => $type ); set_transient('aagl_admin_notices', $notices, 300); } /** * 管理画面通知表示 */ public static function display_admin_notices() { $notices = get_transient('aagl_admin_notices'); if (!empty($notices)) { foreach ($notices as $notice) { $class = 'notice notice-' . esc_attr($notice['type']); echo '

' . esc_html($notice['message']) . '

'; } delete_transient('aagl_admin_notices'); } } /** * データベーステーブル接頭辞付きテーブル名取得 */ public static function get_table_name($table_suffix) { global $wpdb; return $wpdb->prefix . 'aagl_' . $table_suffix; } /** * 安全な配列値取得 */ public static function get_array_value($data_array, $key, $default_value = null) { return isset($data_array[$key]) ? $data_array[$key] : $default_value; } /** * 日時フォーマット(日本語対応) */ public static function format_datetime($datetime, $format = null) { if (empty($datetime)) { return ''; } if ($format === null) { $format = get_option('date_format') . ' ' . get_option('time_format'); } // wp_date()はWP 5.3+で追加。WP 5.0-5.2環境向けフォールバック if (function_exists('wp_date')) { return wp_date($format, strtotime($datetime)); } return date_i18n($format, strtotime($datetime)); } /** * ログ記録 */ public static function log($message, $level = 'info') { if (WP_DEBUG === true) { $log_message = sprintf( '[%s] AAG %s: %s', current_time('mysql'), strtoupper($level), $message ); error_log($log_message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Intentional debug logging } } /** * 長時間処理の実行時間制限を延長 * * 共有ホスティング環境ではmax_execution_timeが30秒に設定されていることが多く、 * AI API呼び出し(120秒タイムアウト)やコンテンツ取得で不足する。 * * @param int $seconds 設定する制限時間(秒)。0で無制限。 * @return bool 設定に成功したかどうか */ public static function extend_execution_time($seconds = 300) { if (function_exists('set_time_limit')) { return @set_time_limit($seconds); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged } return false; } /** * システム情報取得 */ public static function get_system_info() { global $wpdb; return array( 'php_version' => PHP_VERSION, 'wordpress_version' => get_bloginfo('version'), 'plugin_version' => AAGL_VERSION, 'mysql_version' => $wpdb->db_version(), 'max_execution_time' => ini_get('max_execution_time'), 'memory_limit' => ini_get('memory_limit'), 'upload_max_filesize' => ini_get('upload_max_filesize'), 'tables_exist' => AAGL_Database::tables_exist(), 'active_theme' => get_stylesheet(), 'active_plugins' => get_option('active_plugins', array()) ); } /** * 管理画面URL生成 */ public static function get_admin_url($page, $params = array()) { $params['page'] = $page; return add_query_arg($params, admin_url('admin.php')); } /** * 管理者パスワードによるロックが有効かどうか * * wp-config.php に define('AAGL_ADMIN_PASS', '...') が定義されている場合 true * * @return bool */ public static function is_limit_locked() { return defined( 'AAGL_ADMIN_PASS' ) && ! empty( AAGL_ADMIN_PASS ); } /** * 管理者パスワードの検証 * * @param string $input_pass 入力されたパスワード * @return bool */ public static function verify_admin_pass( $input_pass ) { if ( ! self::is_limit_locked() ) { return false; } // bcryptハッシュ ($2y$...) の場合は password_verify、平文の場合は hash_equals でフォールバック $stored = AAGL_ADMIN_PASS; if ( 0 === strpos( $stored, '$2y$' ) || 0 === strpos( $stored, '$2a$' ) ) { return password_verify( $input_pass, $stored ); } return hash_equals( $stored, $input_pass ); } /** * API設定画面がアンロック済みかどうか * * transientベースのセッショントークンで判定(30分有効) * * @return bool */ public static function is_settings_unlocked() { if ( ! self::is_limit_locked() ) { return true; // パスワード未設定なら常にアンロック } $token = get_transient( 'aagl_settings_unlock_' . get_current_user_id() ); return ! empty( $token ); } /** * 当日・当月の記事生成数を1クエリで取得 * * v2.17.0: 集計元を postmeta(_aagl_generated)に変更。 * 旧実装は aagl_generation_history テーブルを集計していたが、同テーブルへの * INSERT経路が存在せず常に0を返し、生成上限が全フローで無効になっていた。 * * @return array ['daily' => int, 'monthly' => int, 'total' => int] */ public static function get_article_counts() { global $wpdb; $today_start = current_time( 'Y-m-d' ) . ' 00:00:00'; $month_start = current_time( 'Y-m' ) . '-01 00:00:00'; $row = $wpdb->get_row( $wpdb->prepare( "SELECT SUM( CASE WHEN p.post_date >= %s THEN 1 ELSE 0 END ) AS daily_count, SUM( CASE WHEN p.post_date >= %s THEN 1 ELSE 0 END ) AS monthly_count, COUNT(*) AS total_count FROM {$wpdb->posts} p INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID AND pm.meta_key = '_aagl_generated' AND pm.meta_value = 'yes' WHERE p.post_status IN ( 'publish', 'future', 'draft', 'pending', 'private' )", $today_start, $month_start ) ); return array( 'daily' => intval( $row->daily_count ?? 0 ), 'monthly' => intval( $row->monthly_count ?? 0 ), 'total' => intval( $row->total_count ?? 0 ), ); } /** * 当日の記事生成数を取得 * * @return int */ public static function get_daily_article_count() { $counts = self::get_article_counts(); return $counts['daily']; } /** * 当月の記事生成数を取得 * * @return int */ public static function get_monthly_article_count() { $counts = self::get_article_counts(); return $counts['monthly']; } /** * 記事生成上限チェック * * @return array ['allowed' => bool, 'reason' => string|null, 'daily_count' => int, 'monthly_count' => int] */ public static function check_article_limit() { $settings = self::get_all_settings(); $system_settings = $settings['system_settings'] ?? array(); $max_daily = intval( $system_settings['max_articles_per_day'] ?? 10 ); $max_monthly = intval( $system_settings['max_articles_per_month'] ?? 0 ); $counts = self::get_article_counts(); $daily_count = $counts['daily']; $monthly_count = $counts['monthly']; // 日間上限チェック if ( $max_daily > 0 && $daily_count >= $max_daily ) { return array( 'allowed' => false, 'reason' => sprintf( '本日の記事生成上限(%d本)に達しました。現在 %d本生成済みです。', $max_daily, $daily_count ), 'daily_count' => $daily_count, 'monthly_count' => $monthly_count, ); } // 月間上限チェック(0 = 無制限) if ( $max_monthly > 0 && $monthly_count >= $max_monthly ) { return array( 'allowed' => false, 'reason' => sprintf( '今月の記事生成上限(%d本)に達しました。現在 %d本生成済みです。', $max_monthly, $monthly_count ), 'daily_count' => $daily_count, 'monthly_count' => $monthly_count, ); } return array( 'allowed' => true, 'reason' => null, 'daily_count' => $daily_count, 'monthly_count' => $monthly_count, ); } /** * 本文内リンクのドメインフィルタ(プロンプトインジェクション対策) * * 第三者コンテンツ経由でAIにスパムリンクを出力させる攻撃への防御。 * 自サイト・参考URLのドメイン・公的ドメイン(go.jp/lg.jp)以外へのタグを除去し、 * リンクテキストのみ残す。相対URL・アンカーは許可。 * * @param string $html 記事本文HTML * @param array $allowed_hosts 追加で許可するホスト名の配列(参考URLのホスト等) * @return string フィルタ済みHTML */ public static function filter_body_links($html, $allowed_hosts = array()) { if (empty($html) || false === stripos($html, ']*>)(.*?)<\/a>/is', function ($m) use ($allowed_hosts) { $tag = $m[0]; $opentag = $m[1]; $inner = $m[2]; // 開始タグ内のみを対象にし、かつ直前が英数字/ハイフンでない場合のみ // 'href'属性とみなす(data-href等の属性名混同を防ぐ) if (!preg_match('/(? 'disabled'); } } return null; } /** * Claude レスポンスに text ブロックが無かったときの診断文(stop_reason とブロック種別) * * @param mixed $data json_decode 済みレスポンス * @return string */ public static function describe_claude_response_shape($data) { if (!is_array($data)) { return 'レスポンスがJSONとして読めません'; } $types = array(); foreach ((array) ($data['content'] ?? array()) as $block) { $types[] = is_array($block) ? ($block['type'] ?? '?') : gettype($block); } return sprintf( 'text ブロックなし (stop_reason=%s, blocks=%s, output_tokens=%s)', $data['stop_reason'] ?? '?', $types ? implode(',', $types) : 'none', $data['usage']['output_tokens'] ?? '?' ); } /** * Claude Messages API レスポンスから本文テキストを取り出す * * Sonnet 5 / Opus 5 以降は既定で思考(thinking)ブロックを本文の前に返すため、 * `content[0]['text']` は存在しない(旧コードはこれを「Unknown error」として失敗扱いにしていた)。 * type=text のブロックを全て連結して返す。無ければ空文字。 * * @param mixed $data json_decode 済みレスポンス(連想配列) * @return string */ public static function extract_claude_text($data) { if (!is_array($data) || empty($data['content']) || !is_array($data['content'])) { return ''; } $parts = array(); foreach ($data['content'] as $block) { if (!is_array($block)) { continue; } $type = $block['type'] ?? 'text'; if ('text' === $type && isset($block['text']) && is_string($block['text'])) { $parts[] = $block['text']; } } return implode("\n", $parts); } /** * AIモデルエラー(モデル廃止等による404)を記録し、管理画面通知で表面化させる * * @param string $provider 'claude' or 'gemini' * @param string $model モデルID * @param string $message エラーメッセージ */ public static function flag_model_error($provider, $model, $message) { $errors = get_option('aagl_model_error', array()); if (!is_array($errors)) { $errors = array(); } $errors[$provider] = array( 'model' => sanitize_text_field((string) $model), 'message' => mb_substr(sanitize_text_field((string) $message), 0, 200), 'time' => current_time('mysql'), ); update_option('aagl_model_error', $errors, false); self::log('Model error flagged for ' . $provider . ' (' . $model . '): ' . $message, 'error'); } /** * AIモデルエラーフラグの解除(API呼び出し成功時) * * @param string $provider 'claude' or 'gemini' */ public static function clear_model_error($provider) { $errors = get_option('aagl_model_error', array()); if (!is_array($errors) || !isset($errors[$provider])) { return; } unset($errors[$provider]); if (empty($errors)) { delete_option('aagl_model_error'); } else { update_option('aagl_model_error', $errors, false); } } /** * SSRF対策: URLが安全かチェック * * ローカルホスト、プライベートIP、予約IPアドレスへのリクエストをブロック。 * * @param string $url チェック対象のURL * @return bool 安全ならtrue、ブロック対象ならfalse */ public static function is_safe_url($url) { $parsed = wp_parse_url($url); if (!$parsed || empty($parsed['host'])) { return false; } $host = strtolower($parsed['host']); // スキーマチェック(http/httpsのみ許可) $scheme = isset($parsed['scheme']) ? strtolower($parsed['scheme']) : ''; if ($scheme !== 'http' && $scheme !== 'https') { return false; } // localhost/loopbackブロック $blocked_hosts = array('localhost', '127.0.0.1', '::1', '0.0.0.0', '[::1]'); if (in_array($host, $blocked_hosts, true)) { self::log('SSRF blocked: localhost/loopback - ' . $url, 'warning'); return false; } // ホスト名を全IP(IPv4 A + IPv6 AAAA)に解決し、各IPを検証する $ips = array(); if (filter_var($host, FILTER_VALIDATE_IP)) { // ホストがIPリテラルの場合はそのまま検証対象に $ips[] = $host; } else { // IPv4(全Aレコード) $ipv4_list = gethostbynamel($host); if (is_array($ipv4_list)) { $ips = array_merge($ips, $ipv4_list); } // IPv6(全AAAAレコード) $aaaa = @dns_get_record($host, DNS_AAAA); if (is_array($aaaa)) { foreach ($aaaa as $record) { if (!empty($record['ipv6'])) { $ips[] = $record['ipv6']; } } } } // 解決できなければブロック(フェイルクローズ) if (empty($ips)) { self::log('SSRF blocked: DNS resolution failed - ' . $url, 'warning'); return false; } // 解決した全IPのうち1つでもプライベート/予約レンジならブロック(IPv4/IPv6両対応) foreach ($ips as $resolved_ip) { if (!filter_var($resolved_ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { self::log('SSRF blocked: private/reserved IP - ' . $url . ' (resolved: ' . $resolved_ip . ')', 'warning'); return false; } } return true; } } /** * グローバル関数: APIキー取得 * * @param string $key_name APIキー名 * @return string APIキー */ // phpcs:ignore Universal.Files.SeparateFunctionsFromOO.Mixed function aagl_get_api_key($key_name) { return AAGL_Utilities::get_api_key($key_name); }