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);
}