init();
}
/**
* 初期化
*/
private function init() {
// wp_cron スケジュール設定(activate時に登録、毎ページロードでは実行しない)
// ※ CRON負荷削減: 'wp'フックからの毎回実行を廃止(2026-03-17)
// Lite版: スケジュール自動生成は持たず、管理画面から1本ずつ同期生成する
add_action('wp_ajax_aagl_generate_now', array($this, 'ajax_generate_now'));
// Ajax アクション
add_action('wp_ajax_aagl_save_template', array($this, 'ajax_save_template'));
add_action('wp_ajax_aagl_delete_template', array($this, 'ajax_delete_template'));
add_action('wp_ajax_aagl_get_attachment_url', array($this, 'ajax_get_attachment_url'));
// テンプレートシステムの初期化
$this->init_default_templates();
}
/**
* デフォルトテンプレートの初期化
*/
private function init_default_templates() {
$templates = $this->get_templates();
if (empty($templates)) {
// SEO Optimized Article Templateのみを初期テンプレートとして作成
$seo_template = $this->get_seo_template_data();
$this->save_template($seo_template);
} else {
// 既存SEOテンプレートのプロンプト・設定を最新版に更新
$this->migrate_seo_template_if_needed($templates);
}
}
/**
* 既存SEOテンプレートのプロンプトと設定を最新版に更新
*/
private function migrate_seo_template_if_needed($templates) {
$migrated = get_option('aagl_seo_template_prompt_v2', false);
if ($migrated) {
return;
}
foreach ($templates as $template) {
if (isset($template['name']) && strpos($template['name'], 'SEO') !== false) {
$updated = $this->get_seo_template_data();
$updated['id'] = $template['id'];
$this->save_template($updated);
AAGL_Utilities::log('SEOテンプレートを最新版プロンプトに更新しました', 'info');
break;
}
}
update_option('aagl_seo_template_prompt_v2', true);
}
/**
* SEO改善版テンプレートデータ
*/
private function get_seo_template_data() {
return array(
'name' => 'SEO Optimized Article Template',
'description' => 'SEO記事作成のための改善版プロンプトテンプレート',
'prompt_template' => $this->get_seo_prompt_template(),
'settings' => array(
'min_length' => 3000,
'max_length' => 8000,
'category_id' => 1,
'tags' => '',
'featured_image' => true,
// コンテキスト設定(初期値は空白)
'company_name' => '',
'company_url' => '',
'exclude_domains' => '',
'target_persona' => '',
'content_tone' => '',
'industry' => '',
// ライターペルソナ設定(初期値は空白)
'writer_name' => '',
'writer_background' => '',
'writer_expertise' => '',
// AI感排除設定(初期値は空白)
'forbidden_expressions' => '',
'recommended_expressions' => '',
),
'active' => true,
'created_at' => current_time('mysql')
);
}
/**
* SEO改善版プロンプトテンプレート本文
*/
private function get_seo_prompt_template() {
return <<<'PROMPT'
# 改善版WordPress記事作成プロンプトテンプレート
あなたは経験豊富なコンテンツライターです。以下の情報を基に、WordPress投稿ページに直接コピー&ペーストできるSEO最適化された記事を作成してください。
## 【記事情報】
- タイトル: {{TITLE}}
- メインキーワード: {{MAIN_KEYWORD}}
- サブキーワード: {{SUB_KEYWORDS}}
## 【参考情報】
{{REFERENCE_SUMMARY}}
参考URL({{REFERENCE_COUNT}}件): {{REFERENCE_SOURCES}}
## 【作業手順】
1. **参考コンテンツ分析**
- {{REFERENCE_SUMMARY}}から具体的なデータ、統計、事例を抽出
- 信頼性の高い情報源からの引用可能な内容を特定
2. **セクション構成の設計**
- サブキーワード{{SUB_KEYWORDS}}に対応した4〜6つのセクションを作成
- 各セクションにメインキーワード{{MAIN_KEYWORD}}を1-2回自然に配置
- 見出しにキーワードを含める(自然な範囲で)
3. **各セクション本文作成**(500-800文字)
- 必ずHTMLタグ(`
`, ``, ``, `- `)を使用
- 具体的な数値、統計、事例を最低1つ含める
- 読者が今すぐ実行できる具体的なアクションを明示
- 1段落は3-4文程度に抑えて可読性を確保
- 専門用語には初出時に簡単な説明を付与
4. **まとめ文作成**(500-700文字程度)
- 主要ポイントの再確認
- 読者への行動喚起(Call to Action)
- 記事で得られる価値の明確化
5. **導入文作成**(300-500文字)
- 読者の課題や悩みに共感
- 記事を読むメリットを明示
- 解決への期待を高める内容
## 【HTML必須要件】
- **すべての見出し**は`
`タグで囲む
- **すべての段落**は`
`タグで囲む
- **重要部分**は``タグで強調
- **箇条書き**は``と`- `タグを使用
- **番号付きリスト**は`
`と`- `タグを使用
## 【SEO最適化要件】
- メインキーワード{{MAIN_KEYWORD}}を各セクションに1-2回自然に配置
- サブキーワード{{SUB_KEYWORDS}}を各セクションに分散配置
- 見出しタグ(`
`)内にキーワードを適切に含める
- 重要キーワードは``タグで強調
## 【コンテンツ品質要件】
- 各セクションに具体的な数値、統計、事例を1つ以上含める
- 実践的で読者が実行可能なアドバイスを提供
- 参考情報から得た信頼性の高いデータを「〜によると」「〜のデータでは」の形式で引用
- 専門用語は分かりやすく説明し、読者の理解を助ける
- 作成した記事には「経験豊富なコンテンツライター」がといった主語は不要。もし主語を記載するなら「{{COMPANY_NAME}}」がとする
## 【可読性要件】
- 1段落は3-4文程度(100-150文字)に抑える
- 適切な改行(``タグ)で視覚的な余白を確保
- 重要ポイントは``タグまたはリスト形式で強調
- 流れるような文章構成で読者を引き込む
## 【品質チェックリスト】
記事作成時に以下を確認:
- □ すべての見出しが``タグで囲まれているか
- □ すべての段落が`
`タグで囲まれているか
- □ キーワードが自然に配置されているか
- □ 各セクションに具体的な数値・事例が含まれているか
- □ 読者が実行可能なアクションが明確か
- □ 参考情報が適切に活用されているか
- □ HTMLタグが正しく使用されているか
## 【出力形式】
WordPress投稿ページにそのまま貼り付けできる完成記事のみを出力してください。
作業過程や分析結果は表示せず、以下の形式で完成記事だけを出力してください:
---
[200-300文字の導入文(読者の課題に共感し、解決への期待を高める内容)]
[セクション1タイトル(キーワードを含む)]
[段落1(3-4文)]
[段落2(3-4文)]
[段落3(3-4文)]
[セクション2タイトル(キーワードを含む)]
[段落1(3-4文)]
[重要ポイント]
[段落2(3-4文)]
- [ステップ1]
- [ステップ2]
- [ステップ3]
[段落3(3-4文)]
[セクション3タイトル(キーワードを含む)]
[段落1(3-4文)]
[段落2(3-4文)]
- [実践ポイント1]
- [実践ポイント2]
- [実践ポイント3]
[段落3(3-4文)]
まとめ
[まとめ段落1(主要ポイントの再確認)]
[まとめ段落2(記事の価値)]
[まとめ段落3(行動喚起)]
参考URL一覧
注:この記事は、参考URLを元にAIによって生成されたものです。最新の正確な情報については、元のニュースソースをご確認ください。
---
**重要:上記の「---」で囲まれた部分のみを出力し、作業過程の説明や分析結果は一切含めないでください。すべてのHTMLタグを正しく使用し、SEO最適化された読みやすい記事を作成してください。**
PROMPT;
}
/**
* テンプレート保存 (WordPress Options API使用)
*/
public function save_template($template_data) {
$templates = get_option('aagl_article_templates', array());
// IDがない場合は新規作成
if (!isset($template_data['id'])) {
$template_data['id'] = 'template_' . wp_generate_uuid4();
}
$templates[$template_data['id']] = $template_data;
$result = update_option('aagl_article_templates', $templates, false);
// update_optionはデータ未変更時もfalseを返すため、データ存在確認で補完
if (!$result) {
$saved = get_option('aagl_article_templates', array());
return isset($saved[$template_data['id']]);
}
return true;
}
/**
* テンプレート一覧取得
*/
public function get_templates() {
return get_option('aagl_article_templates', array());
}
/**
* 特定テンプレート取得
*/
public function get_template($template_id) {
$templates = $this->get_templates();
if (isset($templates[$template_id])) {
return $templates[$template_id];
}
return null;
}
/**
* テンプレート削除
*/
public function delete_template($template_id) {
$templates = $this->get_templates();
if (isset($templates[$template_id])) {
unset($templates[$template_id]);
return update_option('aagl_article_templates', $templates, false);
}
return false;
}
/**
* カスタムクーロン間隔の追加
*/
public function add_cron_intervals($schedules) {
$schedules['biweekly'] = array(
'interval' => 14 * DAY_IN_SECONDS,
'display' => __('隔週(2週間ごと)', 'aio-agent-article-generator'),
);
$schedules['monthly'] = array(
'interval' => 30 * DAY_IN_SECONDS,
'display' => __('毎月(30日ごと)', 'aio-agent-article-generator'),
);
return $schedules;
}
// ========================================
// 🌟 新機能: URL検証機能(2025-09-24追加)
// ========================================
/**
* ✨ 新機能: サブキーワードランダム選択(既存ロジックを関数化)
*/
private function select_random_subkeywords($sub_keywords) {
if (empty($sub_keywords) || !is_array($sub_keywords)) {
return [];
}
// サブキーワードから最大3件をランダム選択
$selected_indices = array_rand($sub_keywords, min(3, count($sub_keywords)));
if (!is_array($selected_indices)) {
$selected_indices = [$selected_indices];
}
$selected = [];
foreach ($selected_indices as $index) {
$selected[] = $sub_keywords[$index];
}
return $selected;
}
/**
* URLが既存リスト(文字列 or ['url'=>...] 構造の混在)に含まれるか判定する。
*/
private function url_already_listed($url, $list) {
foreach ($list as $item) {
$existing = is_array($item) ? ($item['url'] ?? '') : $item;
if ($existing === $url) {
return true;
}
}
return false;
}
/**
* ✨ 新機能: ラウンドロビン方式404チェック
*/
private function validate_urls_round_robin($url_groups) {
$valid_urls = [];
$max_position = 5; // 各組み合わせ最大5件
$timeout_start = time();
$max_validation_time = 20; // 20秒制限
// 各順位ごとにラウンドロビン
for ($position = 0; $position < $max_position; $position++) {
// 時間制限チェック
if (time() - $timeout_start > $max_validation_time) {
AAGL_Utilities::log('URL validation timeout after ' . count($valid_urls) . ' valid URLs');
break;
}
// 各組み合わせのN位をチェック
foreach ($url_groups as $group_index => $group) {
// N位のURLが存在するかチェック
if (!isset($group['urls'][$position])) {
continue;
}
$url = $group['urls'][$position];
// 重複チェック
if ($this->is_url_already_selected($url, $valid_urls)) {
continue;
}
// 除外ドメインチェック(API側で漏れた場合のローカル防御)
if ($this->is_excluded_domain($url)) {
AAGL_Utilities::log('Excluded domain (Group' . ($group_index + 1) . ', Pos' . ($position + 1) . '): ' . $url);
continue;
}
// 404チェック
if ($this->is_url_alive($url)) {
$valid_urls[] = [
'url' => $url,
'title' => $this->extract_title_from_url($url),
'source_query' => $group['query'],
'position' => $position + 1
];
AAGL_Utilities::log('Valid URL found (Group' . ($group_index + 1) . ', Pos' . ($position + 1) . '): ' . $url);
// 5件見つかったら終了
if (count($valid_urls) >= 5) {
return $valid_urls;
}
} else {
AAGL_Utilities::log('Invalid URL (Group' . ($group_index + 1) . ', Pos' . ($position + 1) . '): ' . $url, 'error');
}
}
}
// フェイルセーフ処理
return $this->apply_fallback_if_needed($valid_urls, $url_groups);
}
/**
* ✨ 新機能: URL生存チェック
*
* SSRF対策: is_safe_url()はリダイレクト先までは検証しないため、
* redirectionをwp_remote_headに任せず自前でホップごとに再検証する。
*/
private function is_url_alive($url) {
$max_redirects = 2;
for ($hop = 0; $hop <= $max_redirects; $hop++) {
if (!AAGL_Utilities::is_safe_url($url)) {
return false;
}
$response = wp_remote_head($url, [
'timeout' => 3,
'redirection' => 0,
'user-agent' => 'Mozilla/5.0 (compatible; AAG-Validator/1.0)',
// 意図的にfalse: サードパーティURLの生存確認では、期限切れ・自己署名証明書の
// サイトも正常とみなす必要がある。AI APIへの通信はsslverify=>trueを使用。
'sslverify' => false,
]);
if (is_wp_error($response)) {
return false;
}
$status = wp_remote_retrieve_response_code($response);
if ($status >= 300 && $status < 400) {
$location = wp_remote_retrieve_header($response, 'location');
if (empty($location)) {
return false;
}
$next_url = $this->resolve_redirect_location($url, $location);
if (empty($next_url)) {
return false;
}
$url = $next_url;
continue;
}
return ($status >= 200 && $status < 300);
}
// リダイレクト上限超過
return false;
}
/**
* リダイレクト先Locationヘッダーを絶対URLに解決する
*
* @param string $base_url 直前にリクエストしたURL
* @param string $location Locationヘッダーの値(相対/絶対どちらもありうる)
* @return string|false 解決できた絶対URL、失敗時はfalse
*/
private function resolve_redirect_location($base_url, $location) {
$location = trim($location);
if ('' === $location) {
return false;
}
// 既に絶対URL(スキーム付き、またはプロトコル相対//)
if (preg_match('#^https?://#i', $location) || 0 === strpos($location, '//')) {
$scheme = wp_parse_url($base_url, PHP_URL_SCHEME) ?: 'https';
return 0 === strpos($location, '//') ? $scheme . ':' . $location : $location;
}
$base_scheme = wp_parse_url($base_url, PHP_URL_SCHEME);
$base_host = wp_parse_url($base_url, PHP_URL_HOST);
if (empty($base_scheme) || empty($base_host)) {
return false;
}
$base_port = wp_parse_url($base_url, PHP_URL_PORT);
$authority = $base_host . ($base_port ? ':' . $base_port : '');
if ('/' === $location[0]) {
// ルート相対パス
return $base_scheme . '://' . $authority . $location;
}
// パス相対(簡易解決: ベースURLのディレクトリに連結)
$base_path = wp_parse_url($base_url, PHP_URL_PATH);
$base_dir = $base_path ? preg_replace('#/[^/]*$#', '/', $base_path) : '/';
return $base_scheme . '://' . $authority . $base_dir . $location;
}
/**
* ✨ 新機能: 重複URL確認
*/
private function is_url_already_selected($url, $selected_urls) {
foreach ($selected_urls as $selected) {
if ($selected['url'] === $url) {
return true;
}
}
return false;
}
/**
* 除外ドメインに該当するURLかチェック(ローカル防御)
*/
private function is_excluded_domain($url) {
if (empty($this->exclude_domains_list)) {
return false;
}
$host = wp_parse_url($url, PHP_URL_HOST);
if (empty($host)) {
return false;
}
$host = preg_replace('/^www\./', '', strtolower($host));
foreach ($this->exclude_domains_list as $excluded) {
$excluded = strtolower($excluded);
// 完全一致またはサブドメイン一致(例: blog.example.com が example.com に該当)
if ($host === $excluded || substr($host, -strlen('.' . $excluded)) === '.' . $excluded) {
return true;
}
}
return false;
}
/**
* ✨ 新機能: フェイルセーフ処理
*/
private function apply_fallback_if_needed($valid_urls, $url_groups) {
if (count($valid_urls) >= 3) {
return $valid_urls;
}
AAGL_Utilities::log('Insufficient valid URLs (' . count($valid_urls) . '), applying fallback', 'warning');
// 各グループから除外ドメインに該当しないURLを1つずつ取得
$fallback_urls = [];
foreach ($url_groups as $group) {
if (!empty($group['urls'])) {
foreach ($group['urls'] as $candidate_url) {
if ($this->is_excluded_domain($candidate_url)) {
continue;
}
if ($this->is_url_already_selected($candidate_url, $valid_urls) || $this->is_url_already_selected($candidate_url, $fallback_urls)) {
continue;
}
$fallback_urls[] = [
'url' => $candidate_url,
'source_query' => $group['query'],
'position' => 1,
'fallback' => true
];
break;
}
if (count($valid_urls) + count($fallback_urls) >= 5) {
break;
}
}
}
return array_merge($valid_urls, array_slice($fallback_urls, 0, 5 - count($valid_urls)));
}
/**
* 日本語URLを既存結果にマージ(重複排除、最大5件維持)
*/
private function merge_japanese_urls($existing_urls, $new_urls) {
$existing_url_set = array();
foreach ($existing_urls as $url_data) {
$existing_url_set[] = $url_data['url'] ?? '';
}
foreach ($new_urls as $url_data) {
$url = $url_data['url'] ?? '';
if (empty($url) || in_array($url, $existing_url_set, true)) {
continue;
}
if ($this->is_japanese_url($url)) {
$existing_urls[] = $url_data;
$existing_url_set[] = $url;
if (count($existing_urls) >= 5) {
break;
}
}
}
return array_slice($existing_urls, 0, 5);
}
/**
* URLが日本語サイトかどうかを判定
*/
private function is_japanese_url($url) {
$host = wp_parse_url($url, PHP_URL_HOST);
if (empty($host)) {
return false;
}
// .jp TLD判定
$ja_tlds = array('.jp', '.co.jp', '.or.jp', '.ne.jp', '.ac.jp', '.go.jp');
foreach ($ja_tlds as $tld) {
if (substr($host, -strlen($tld)) === $tld) {
return true;
}
}
// 主要日本語ニュース・メディアサイト(.com等の非.jpドメイン)
$ja_domains = array(
'asahi.com', 'nikkei.com', 'toyokeizai.net', 'diamond.jp',
'itmedia.co.jp', 'president.jp', 'businessinsider.jp',
'yahoo.co.jp', 'livedoor.com', 'hatena.ne.jp',
);
foreach ($ja_domains as $domain) {
if ($host === $domain || substr($host, -(strlen($domain) + 1)) === '.' . $domain) {
return true;
}
}
return false;
}
/**
* 🔄 修正版: 記事生成(統一キーワード対応)
*/
private function generate_single_article_enhanced($keyword_data, $template) {
// 共有ホスティング対応: 実行時間制限を延長
AAGL_Utilities::extend_execution_time(300);
$start_time = microtime(true);
// 確定されたサブキーワードを使用
$main_keyword_id = $keyword_data['id'];
$main_keyword_text = $keyword_data['main_keyword'];
$confirmed_sub_keywords = $keyword_data['confirmed_sub_keywords'] ?? [];
$validated_reference_urls = $keyword_data['validated_reference_urls'] ?? [];
AAGL_Utilities::log('Generating article with confirmed keywords: ' . $main_keyword_text . ' + [' . implode(', ', array_column($confirmed_sub_keywords, 'sub_keyword')) . ']');
AAGL_Utilities::log('Using ' . count($validated_reference_urls) . ' validated reference URLs');
try {
$keyword_functions = AAGL_Keyword_Functions::get_instance();
$ai_integration = AAGL_AI_Integration::get_instance();
// Step 1: タイトル候補生成(既存ロジック活用)
// 「自動」設定を含めて1箇所で解決する(従来はautoが下の分岐で例外になっていた)
$ai_provider = AAGL_Utilities::determine_provider();
$selected_sub_keyword_ids = array_column($confirmed_sub_keywords, 'id');
// テンプレート情報からタイトル生成オプションを構築
// forbidden_expressions: テンプレートの禁止表現をタイトル生成プロンプトにも注入する
// (タイトルは本文と別経路のためテンプレート規則が届かなかった問題への対処)
$title_options = array(
'template_type' => $template['settings']['template_type'] ?? 'standard',
'category_id' => $template['settings']['category_id'] ?? null,
'post_type' => AAGL_Utilities::get_post_type_setting(),
'forbidden_expressions' => $template['settings']['forbidden_expressions'] ?? '',
);
$title_result = $keyword_functions->generate_title_candidates_internal($main_keyword_id, $selected_sub_keyword_ids, $ai_provider, $title_options);
if (!$title_result['success']) {
throw new Exception('Title generation failed: ' . $title_result['error']);
}
$titles = $title_result['titles'];
// 禁止表現を含まない候補を優先採用(全候補が該当する場合は先頭)
$selected_title = AAGL_AI_Integration::select_title_avoiding_forbidden(
$titles,
$template['settings']['forbidden_expressions'] ?? ''
);
AAGL_Utilities::log('Scheduled: Generated title: ' . $selected_title);
// Step 2: 参考コンテンツ収集(検証済みURLを使用)
$reference_content = '';
$collected_contents = array();
if (!empty($validated_reference_urls)) {
// 収集したコンテンツをフォーマット
foreach ($collected_contents as $content) {
$reference_content .= "【参考情報】\n" . $content['content'] . "\n\n";
}
}
// 実際に本文抜粋をプロンプトへ渡したURLのみを「参考URL」として扱う(記事末尾一覧の誠実性担保)
$used_reference_urls = array();
foreach ($collected_contents as $cc) {
$cc_url = $cc['url'];
$cc_title = '';
$used_reference_urls[] = array('url' => $cc_url, 'title' => $cc_title);
}
AAGL_Utilities::log('Scheduled: Reference content length: ' . mb_strlen($reference_content) . ' / used reference URLs: ' . count($used_reference_urls));
// Step 3: 記事生成用パラメーター準備
// post_type: 過去記事タイトル取得・構成多様化がCPTサイトで'post'を参照してしまう不具合の修正
$generation_params = array(
'main_keyword' => $main_keyword_text,
'sub_keywords' => array_column($confirmed_sub_keywords, 'sub_keyword'),
'title' => $selected_title,
'reference_content' => $reference_content,
'reference_urls' => $used_reference_urls,
'provider' => $ai_provider,
'template' => $template,
'post_type' => AAGL_Utilities::get_post_type_setting(),
);
// Step 4: AI記事生成
// プロバイダ判定とモデル廃止時のフォールバックは generate_structured_article() に集約
$article_result = $ai_integration->generate_structured_article($generation_params);
if (!$article_result['success']) {
throw new Exception('Article generation failed: ' . $article_result['error']);
}
// Step 5: 統一されたsave_generated_article()でWordPress投稿として保存
$save_params = array(
'title' => $selected_title,
'main_keyword' => $main_keyword_text,
'sub_keywords' => array_column($confirmed_sub_keywords, 'sub_keyword'),
'reference_urls' => $validated_reference_urls,
'generated_by' => 'scheduled_generation',
'template_id' => $template['id'],
'author_id' => $keyword_data['author_id'] ?? null,
'category_id' => $template['settings']['category_id'] ?? null,
'post_type' => AAGL_Utilities::get_post_type_setting(),
'provider' => $ai_provider,
'log_history' => true,
'keyword_data' => array(
'main_keyword_id' => $main_keyword_id,
'main_keyword' => $main_keyword_text,
'sub_keywords' => $confirmed_sub_keywords,
'title' => $selected_title,
'reference_urls' => array_column($validated_reference_urls, 'url'),
),
);
$save_result = $ai_integration->save_generated_article($article_result, $save_params);
if (!$save_result['success']) {
throw new Exception('Failed to save article: ' . $save_result['error']);
}
$post_id = $save_result['post_id'];
AAGL_Utilities::log('Scheduled: Article created successfully using enhanced method. Post ID: ' . $post_id);
return array(
'success' => true,
'post_id' => $post_id,
'keyword' => $main_keyword_text,
'title' => $selected_title,
'sub_keywords' => array_column($confirmed_sub_keywords, 'sub_keyword'),
'processing_time' => round((microtime(true) - $start_time) * 1000, 2),
'message' => 'Article generated successfully using enhanced URL validation'
);
} catch (Exception $e) {
AAGL_Utilities::log('Scheduled: Error in generate_single_article_enhanced: ' . $e->getMessage(), 'error');
// 失敗理由を aagl_schedule_log に残す(WP_DEBUGオフの本番では error_log に何も残らず、
// 汎用文だけでは原因を追えないため。三幸福祉会 2026-09-05 の無言失敗で判明)
$error_detail = mb_substr(sanitize_text_field($e->getMessage()), 0, 500);
return array(
'success' => false,
'keyword' => $main_keyword_text ?? 'Unknown',
'error' => '記事生成中にエラーが発生しました: ' . $error_detail,
'error_class' => get_class($e),
'failed_at' => current_time('mysql'),
);
}
}
// Ajax handlers
/**
* Ajax: テンプレート削除
*/
public function ajax_delete_template() {
check_ajax_referer('aagl_ajax_nonce', 'nonce');
if ( ! current_user_can( 'edit_others_posts' ) ) {
wp_send_json_error( array( 'message' => __( 'Permission denied.', 'aio-agent-article-generator' ) ) );
}
$template_id = sanitize_text_field(wp_unslash($_POST['template_id'] ?? ''));
if (empty($template_id)) {
wp_send_json_error('Template ID required');
}
$result = $this->delete_template($template_id);
if ($result) {
wp_send_json_success(array('message' => 'Template deleted successfully'));
} else {
wp_send_json_error('Failed to delete template');
}
}
/**
* 添付ファイルURLの取得(テンプレート編集時のデフォルト画像プレビュー用)
*/
public function ajax_get_attachment_url() {
check_ajax_referer('aagl_ajax_nonce', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => __('Permission denied.', 'aio-agent-article-generator')));
}
$attachment_id = intval(wp_unslash($_POST['attachment_id'] ?? 0));
if ($attachment_id <= 0) {
wp_send_json_error(array('message' => 'Invalid attachment ID'));
}
$url = wp_get_attachment_image_url($attachment_id, 'medium');
if (!$url) {
wp_send_json_error(array('message' => 'Attachment not found'));
}
wp_send_json_success(array('url' => $url));
}
// ================================
// Week 2: スケジュール投稿機能
// ================================
/**
* スケジュール実行ロックの取得
* @param string $schedule_id スケジュールID
* @param int $timeout タイムアウト時間(秒)
* @return bool ロック取得成功時true
*/
private function acquire_execution_lock($schedule_id, $timeout = 300) {
$lock_key = 'aagl_executing_' . $schedule_id;
// 既に実行中かチェック
if (get_transient($lock_key)) {
AAGL_Utilities::log('Schedule ' . $schedule_id . ' is already executing, skipping', 'warning');
return false;
}
// ロック設定(デフォルト5分間)
set_transient($lock_key, time(), $timeout);
return true;
}
/**
* スケジュール実行ロックの解除
* @param string $schedule_id スケジュールID
*/
private function release_execution_lock($schedule_id) {
$lock_key = 'aagl_executing_' . $schedule_id;
delete_transient($lock_key);
}
/**
* 当日実行済みチェック
* wp_optionベース(キャッシュプラグインによるTransient消失を回避)
* @param string $schedule_id スケジュールID
* @return bool 実行済みの場合true
*/
private function is_executed_today($schedule_id) {
$option_key = '_aagl_executed_' . $schedule_id;
$last_date = get_option($option_key, '');
return $last_date === wp_date('Y-m-d');
}
/**
* 実行済みフラグの設定
* wp_optionベース(キャッシュプラグインによるTransient消失を回避)
* @param string $schedule_id スケジュールID
*/
private function mark_executed_today($schedule_id) {
$option_key = '_aagl_executed_' . $schedule_id;
update_option($option_key, wp_date('Y-m-d'), false);
}
/**
* サブキーワードの確定(未使用優先ローテーション)
*
* 使用済み(used)を除いたactiveから選び、active残数が3未満になったら
* usedをactiveに戻して全サブKWを巡回させる(同KW反復による記事類似化の防止)。
* スケジュール生成と手動生成(Lite版)の両方から使う。
*
* @param int $main_keyword_id メインキーワードID
* @return array 選ばれたサブキーワード行の配列
*/
private function select_sub_keywords_with_rotation($main_keyword_id) {
global $wpdb;
$all_sub_keywords = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}aagl_sub_keywords WHERE main_keyword_id = %d AND status = 'active' ORDER BY RAND() LIMIT 10",
$main_keyword_id
),
ARRAY_A
);
if (count($all_sub_keywords) < 3) {
$reset_count = $wpdb->query(
$wpdb->prepare(
"UPDATE {$wpdb->prefix}aagl_sub_keywords SET status = 'active' WHERE main_keyword_id = %d AND status = 'used'",
$main_keyword_id
)
);
if ($reset_count) {
AAGL_Utilities::log('Sub keyword rotation: ' . $reset_count . '件のusedをactiveに戻して巡回を再開 (main_keyword_id=' . $main_keyword_id . ')');
$all_sub_keywords = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}aagl_sub_keywords WHERE main_keyword_id = %d AND status = 'active' ORDER BY RAND() LIMIT 10",
$main_keyword_id
),
ARRAY_A
);
}
}
return $this->select_random_subkeywords($all_sub_keywords);
}
/**
* 記事を1本その場で生成する(Lite版「記事生成」ページから同期実行)
*
* 予約実行と同じ工程を通す:
* 上限チェック → サブKW確定(ローテーション) → 生成 → 投稿ステータス反映 → メタ保存。
* 参考URLの検索(Web検索連携)は行わない。
*
* @param int $main_keyword_id メインキーワードID
* @param string $template_id テンプレートID
* @param string $post_status 'draft' | 'publish'
* @return array success / post_id / edit_url / error
*/
public function generate_now($main_keyword_id, $template_id, $post_status = 'draft') {
$limit_check = AAGL_Utilities::check_article_limit();
if (!$limit_check['allowed']) {
return array('success' => false, 'error' => $limit_check['reason']);
}
$template = $this->get_template($template_id);
if (!$template) {
return array('success' => false, 'error' => __('テンプレートが見つかりません。', 'aio-agent-article-generator'));
}
$keyword_functions = AAGL_Keyword_Functions::get_instance();
$main_keyword = $keyword_functions->get_main_keyword_by_id($main_keyword_id);
if (!$main_keyword) {
return array('success' => false, 'error' => __('キーワードが見つかりません。有効なメインキーワードを選択してください。', 'aio-agent-article-generator'));
}
// 二重実行防止(生成は数分かかるため、ボタン連打・多重タブを塞ぐ)
$lock_key = 'aagl_generate_now_lock';
if (get_transient($lock_key)) {
return array('success' => false, 'error' => __('別の記事を生成中です。完了してからもう一度お試しください。', 'aio-agent-article-generator'));
}
set_transient($lock_key, time(), 900);
try {
$selected_sub_keywords = $this->select_sub_keywords_with_rotation((int) $main_keyword_id);
AAGL_Utilities::log('Manual: Selected sub keywords: ' . implode(', ', array_column($selected_sub_keywords, 'sub_keyword')));
$keyword_data = array(
'id' => (int) $main_keyword_id,
'main_keyword' => $main_keyword['keyword'],
'confirmed_sub_keywords' => $selected_sub_keywords,
'validated_reference_urls' => array(),
'is_scheduled_generation' => false,
'author_id' => get_current_user_id(),
);
$result = $this->generate_single_article_enhanced($keyword_data, $template);
if (empty($result['success']) || empty($result['post_id'])) {
return array(
'success' => false,
'error' => $result['error'] ?? __('記事の生成に失敗しました。', 'aio-agent-article-generator'),
);
}
$post_id = (int) $result['post_id'];
// 使用したサブKWをusedにマーク(未使用優先ローテーション用)
global $wpdb;
$used_sub_ids = array_filter(array_map('intval', array_column($selected_sub_keywords, 'id')));
if (!empty($used_sub_ids)) {
$ids_placeholder = implode(',', array_fill(0, count($used_sub_ids), '%d'));
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- IN句の %d は件数に応じて組み立てる
$wpdb->query($wpdb->prepare("UPDATE {$wpdb->prefix}aagl_sub_keywords SET status = 'used' WHERE id IN ($ids_placeholder)", $used_sub_ids));
}
if ('publish' === $post_status) {
wp_update_post(array('ID' => $post_id, 'post_status' => 'publish'));
}
update_post_meta($post_id, '_aagl_generated', 'yes');
update_post_meta($post_id, '_aagl_generation_date', current_time('mysql'));
update_post_meta($post_id, '_aagl_generation_source', 'manual');
update_post_meta($post_id, '_aagl_main_keyword', $main_keyword['keyword']);
update_post_meta($post_id, '_aagl_processing_time', $result['processing_time'] ?? 0);
if (!empty($selected_sub_keywords)) {
update_post_meta($post_id, '_aagl_sub_keywords', wp_json_encode(array_column($selected_sub_keywords, 'sub_keyword')));
}
return array(
'success' => true,
'post_id' => $post_id,
'title' => get_the_title($post_id),
'post_status' => get_post_status($post_id),
'edit_url' => get_edit_post_link($post_id, 'raw'),
'view_url' => get_permalink($post_id),
'processing_time' => $result['processing_time'] ?? 0,
);
} finally {
delete_transient($lock_key);
}
}
/**
* Ajax: 記事をその場で生成(Lite版)
*/
public function ajax_generate_now() {
check_ajax_referer('aagl_ajax_nonce', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => __('Permission denied.', 'aio-agent-article-generator')));
}
$main_keyword_id = intval(wp_unslash($_POST['main_keyword_id'] ?? 0));
$template_id = sanitize_text_field(wp_unslash($_POST['template_id'] ?? ''));
$post_status = sanitize_text_field(wp_unslash($_POST['post_status'] ?? 'draft'));
$post_status = in_array($post_status, array('draft', 'publish'), true) ? $post_status : 'draft';
if ($main_keyword_id <= 0 || '' === $template_id) {
wp_send_json_error(array('message' => __('テンプレートとキーワードを選択してください。', 'aio-agent-article-generator')));
}
// 生成は数分かかる。ブラウザが切断されても処理を最後まで通す
ignore_user_abort(true);
AAGL_Utilities::extend_execution_time(600);
$result = $this->generate_now($main_keyword_id, $template_id, $post_status);
if (empty($result['success'])) {
wp_send_json_error(array('message' => $result['error'] ?? __('記事の生成に失敗しました。', 'aio-agent-article-generator')));
}
wp_send_json_success($result);
}
/**
* Ajax: テンプレート保存
*/
public function ajax_save_template() {
check_ajax_referer('aagl_ajax_nonce', 'nonce');
if ( ! current_user_can( 'edit_others_posts' ) ) {
wp_send_json_error( array( 'message' => __( 'Permission denied.', 'aio-agent-article-generator' ) ) );
}
// プロンプトテンプレートは記事生成の指示文で、見出しや箇条書きのHTMLを含む。
// sanitize_text_field ではその組版が壊れるため、投稿本文と同じ wp_kses_post で通す。
$prompt_template = '';
if (!empty($_POST['prompt_template'])) {
$prompt_template = wp_kses_post(wp_unslash($_POST['prompt_template']));
}
// 記事タイプ / 根拠モード / 根拠なし時の動作 — ホワイトリスト検証
// (クロージャ内で $_POST を読むと静的解析が nonce 検証を追えないため、ここで読む)
$raw_template_type = sanitize_text_field(wp_unslash($_POST['template_type'] ?? 'standard'));
$raw_evidence_mode = sanitize_text_field(wp_unslash($_POST['evidence_mode'] ?? 'off'));
$raw_no_source_behavior = sanitize_text_field(wp_unslash($_POST['no_source_behavior'] ?? 'skip'));
$template_data = array(
'id' => sanitize_text_field(wp_unslash($_POST['template_id'] ?? '')),
'name' => sanitize_text_field(wp_unslash($_POST['name'] ?? '')),
'description' => sanitize_textarea_field(wp_unslash($_POST['description'] ?? '')),
'prompt_template' => $prompt_template,
'settings' => array(
'min_length' => intval(wp_unslash($_POST['min_length'] ?? 800)),
'max_length' => intval(wp_unslash($_POST['max_length'] ?? 2000)),
'category_id' => intval(wp_unslash($_POST['category_id'] ?? 0)),
'featured_image' => isset($_POST['featured_image']) ? (bool) sanitize_text_field(wp_unslash($_POST['featured_image'])) : true,
'default_featured_image_id' => intval(wp_unslash($_POST['default_featured_image_id'] ?? 0)),
// 記事タイプ(standard / news)。Lite版は standard 固定
'template_type' => 'standard',
// 根拠モード。Lite版は off 固定
'evidence_mode' => 'off',
'no_source_behavior' => in_array($raw_no_source_behavior, array('skip', 'generate_conservative'), true) ? $raw_no_source_behavior : 'skip',
// コンテキスト設定
'company_name' => sanitize_text_field(wp_unslash($_POST['company_name'] ?? '')),
'company_url' => esc_url_raw(wp_unslash($_POST['company_url'] ?? '')),
'exclude_domains' => sanitize_textarea_field(wp_unslash($_POST['exclude_domains'] ?? '')),
'target_persona' => sanitize_textarea_field(wp_unslash($_POST['target_persona'] ?? '')),
'content_tone' => sanitize_text_field(wp_unslash($_POST['content_tone'] ?? '')),
'industry' => sanitize_text_field(wp_unslash($_POST['industry'] ?? '')),
// ライターペルソナ設定
'writer_name' => sanitize_text_field(wp_unslash($_POST['writer_name'] ?? '')),
'writer_background' => sanitize_textarea_field(wp_unslash($_POST['writer_background'] ?? '')),
'writer_expertise' => sanitize_text_field(wp_unslash($_POST['writer_expertise'] ?? '')),
// AI感排除設定
'forbidden_expressions' => sanitize_textarea_field(wp_unslash($_POST['forbidden_expressions'] ?? '')),
'recommended_expressions' => sanitize_textarea_field(wp_unslash($_POST['recommended_expressions'] ?? '')),
),
'active' => isset($_POST['active']) ? (bool) sanitize_text_field(wp_unslash($_POST['active'])) : true
);
// 新規作成の場合はIDを生成
if (empty($template_data['id'])) {
$template_data['id'] = 'template_' . wp_generate_uuid4();
}
if (empty($template_data['name']) || empty($template_data['prompt_template'])) {
wp_send_json_error('テンプレート名とプロンプトは必須です。');
}
$result = $this->save_template($template_data);
if ($result) {
wp_send_json_success(array(
'message' => 'テンプレートが保存されました。',
'template_id' => $template_data['id']
));
} else {
wp_send_json_error('テンプレートの保存に失敗しました。');
}
}
/**
* URLからタイトルを抽出(ドメイン名とパスから推測)
*/
private function extract_title_from_url($url) {
$parsed = wp_parse_url($url);
if (!$parsed || !isset($parsed['host'])) {
return $url; // パースできない場合はURL全体を返す
}
$domain = $parsed['host'];
// www.を削除
$domain = preg_replace('/^www\./', '', $domain);
// パスがある場合は最後の部分を取得
if (isset($parsed['path']) && '/' !== $parsed['path']) {
$path_parts = explode('/', trim($parsed['path'], '/'));
$last_part = end($path_parts);
// 拡張子を削除
$last_part = preg_replace('/\.[^.]+$/', '', $last_part);
if (!empty($last_part)) {
return $domain . ' - ' . $last_part;
}
}
return $domain;
}
}
// フィルターでカスタムクーロン間隔を追加
add_filter('cron_schedules', array(AAGL_WordPress_Native_System::get_instance(), 'add_cron_intervals'));
// WordPress起動時にインスタンス化
add_action('init', function() {
AAGL_WordPress_Native_System::get_instance();
});