对齐 Zibll 的 AI 生成 SEO 流程:统一 WP AI Client/主题 Provider 双后端、文章/分类 SEO 框与生成回填、投稿摘要/标签建议,并收敛扩展页为连接配置。
282 lines
9.0 KiB
PHP
282 lines
9.0 KiB
PHP
<?php
|
||
/**
|
||
* AKSRT AI SEO — SEO 字段生成(对齐 Zibll 闭环)
|
||
*
|
||
* @package AKSRT
|
||
*/
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
class AKSRT_AI_SEO {
|
||
|
||
private static $instance = null;
|
||
|
||
public static function get_instance(): self {
|
||
if (null === self::$instance) {
|
||
self::$instance = new self();
|
||
}
|
||
return self::$instance;
|
||
}
|
||
|
||
/**
|
||
* SEO AI 功能是否启用。
|
||
*/
|
||
public function is_enabled(): bool {
|
||
if (!class_exists('AKSRT_Settings')) {
|
||
return false;
|
||
}
|
||
$seo = AKSRT_Settings::get_seo_settings();
|
||
return !empty($seo['seo_enabled']) && !empty($seo['seo_ai_enabled']);
|
||
}
|
||
|
||
/**
|
||
* 从已保存实体生成 SEO 三字段(Zibll 形状)。
|
||
*
|
||
* @return array{success:bool,data?:array,message?:string}
|
||
*/
|
||
public function generate_fields(string $type, int $id): array {
|
||
if (!$this->is_enabled()) {
|
||
return [
|
||
'success' => false,
|
||
'message' => __('AI SEO 功能未启用', 'aksrt'),
|
||
];
|
||
}
|
||
|
||
if (!class_exists('AKSRT_AI') || !class_exists('AKSRT_AI_Prompt')) {
|
||
return [
|
||
'success' => false,
|
||
'message' => __('AI 模块未加载', 'aksrt'),
|
||
];
|
||
}
|
||
|
||
$ai = AKSRT_AI::get_instance();
|
||
if (!$ai->is_available()) {
|
||
return [
|
||
'success' => false,
|
||
'message' => __('AI 服务未启用或未配置', 'aksrt'),
|
||
];
|
||
}
|
||
|
||
$type = $type === 'term' ? 'term' : 'post';
|
||
if ($id <= 0) {
|
||
return [
|
||
'success' => false,
|
||
'message' => __('请先保存后再生成 SEO', 'aksrt'),
|
||
];
|
||
}
|
||
|
||
$context = $type === 'term'
|
||
? $this->build_term_context($id)
|
||
: $this->build_post_context($id);
|
||
|
||
if (is_wp_error($context)) {
|
||
return [
|
||
'success' => false,
|
||
'message' => $context->get_error_message(),
|
||
];
|
||
}
|
||
|
||
$prompt = AKSRT_AI_Prompt::get_instance();
|
||
|
||
$title = $this->run_field(
|
||
$ai,
|
||
$context,
|
||
$prompt->get_seo_title_system_instruction(),
|
||
'title'
|
||
);
|
||
$keywords = $this->run_field(
|
||
$ai,
|
||
$context,
|
||
$prompt->get_seo_keywords_system_instruction(),
|
||
'keywords'
|
||
);
|
||
$description = $this->run_field(
|
||
$ai,
|
||
$context,
|
||
$prompt->get_seo_description_system_instruction(),
|
||
'description'
|
||
);
|
||
|
||
return [
|
||
'success' => true,
|
||
'data' => [
|
||
'title' => $title,
|
||
'keywords' => $keywords,
|
||
'description' => $description,
|
||
],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 草稿场景:用传入 title/content 生成(兼容旧 /ai/seo-suggest)。
|
||
*/
|
||
public function suggest(string $title, string $content = ''): array {
|
||
if (!$this->is_enabled()) {
|
||
return ['success' => false, 'message' => __('AI SEO 功能未启用', 'aksrt')];
|
||
}
|
||
|
||
if (!class_exists('AKSRT_AI') || !class_exists('AKSRT_AI_Prompt')) {
|
||
return ['success' => false, 'message' => __('AI 模块未加载', 'aksrt')];
|
||
}
|
||
|
||
$ai = AKSRT_AI::get_instance();
|
||
if (!$ai->is_available()) {
|
||
return ['success' => false, 'message' => __('AI 服务未启用', 'aksrt')];
|
||
}
|
||
|
||
$title = trim($title);
|
||
$content = trim(wp_strip_all_tags($content));
|
||
if ($title === '' && $content === '') {
|
||
return [
|
||
'success' => false,
|
||
'message' => __('无可用内容,请先填写标题或正文', 'aksrt'),
|
||
];
|
||
}
|
||
|
||
$parts = [];
|
||
if ($title !== '') {
|
||
$parts[] = '标题: ' . $title;
|
||
}
|
||
if ($content !== '') {
|
||
$content = wp_trim_words($content, 1500, '');
|
||
$parts[] = "正文:\n" . $content;
|
||
}
|
||
$context = implode("\n\n", $parts);
|
||
$prompt = AKSRT_AI_Prompt::get_instance();
|
||
|
||
$title_out = $this->run_field($ai, $context, $prompt->get_seo_title_system_instruction(), 'title');
|
||
$kw_out = $this->run_field($ai, $context, $prompt->get_seo_keywords_system_instruction(), 'keywords');
|
||
$desc_out = $this->run_field($ai, $context, $prompt->get_seo_description_system_instruction(), 'description');
|
||
|
||
$keywords = [];
|
||
if (!empty($kw_out['content'])) {
|
||
$keywords = array_values(array_filter(array_map('trim', explode(',', $kw_out['content']))));
|
||
}
|
||
|
||
return [
|
||
'success' => true,
|
||
'data' => [
|
||
'suggested_title' => (string) ($title_out['content'] ?? ''),
|
||
'suggested_description' => (string) ($desc_out['content'] ?? ''),
|
||
'suggested_keywords' => $keywords,
|
||
'fields' => [
|
||
'title' => $title_out,
|
||
'keywords' => $kw_out,
|
||
'description' => $desc_out,
|
||
],
|
||
],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @return string|WP_Error
|
||
*/
|
||
public function build_post_context(int $post_id, int $word_limit = 1500) {
|
||
$post = get_post($post_id);
|
||
if (!$post) {
|
||
return new WP_Error('no_post', __('内容不存在', 'aksrt'));
|
||
}
|
||
|
||
$title = trim((string) $post->post_title);
|
||
$excerpt = trim((string) $post->post_excerpt);
|
||
$content = wp_strip_all_tags((string) $post->post_content);
|
||
$content = trim(preg_replace('/\s+/u', ' ', $content) ?? $content);
|
||
$content = wp_trim_words($content, max(50, $word_limit), '');
|
||
|
||
if ($title === '' && $content === '' && $excerpt === '') {
|
||
return new WP_Error('empty_content', __('无可用内容,请先填写标题、摘要或正文并保存', 'aksrt'));
|
||
}
|
||
|
||
$parts = [];
|
||
if ($title !== '') {
|
||
$parts[] = '标题: ' . $title;
|
||
}
|
||
if ($excerpt !== '') {
|
||
$parts[] = '摘要: ' . $excerpt;
|
||
}
|
||
if ($content !== '') {
|
||
$parts[] = "正文:\n" . $content;
|
||
}
|
||
|
||
return implode("\n\n", $parts);
|
||
}
|
||
|
||
/**
|
||
* @return string|WP_Error
|
||
*/
|
||
public function build_term_context(int $term_id) {
|
||
$term = get_term($term_id);
|
||
if (!$term || is_wp_error($term)) {
|
||
return new WP_Error('no_term', __('分类/标签不存在', 'aksrt'));
|
||
}
|
||
|
||
$parts = [];
|
||
if ($term->name !== '') {
|
||
$parts[] = '标题: ' . $term->name;
|
||
}
|
||
if ($term->description !== '') {
|
||
$parts[] = '描述: ' . wp_strip_all_tags((string) $term->description);
|
||
}
|
||
|
||
if (empty($parts)) {
|
||
return new WP_Error('empty_term', __('没有可用的标题或描述', 'aksrt'));
|
||
}
|
||
|
||
return implode("\n\n", $parts);
|
||
}
|
||
|
||
/**
|
||
* @return array{content?:string,error?:string}
|
||
*/
|
||
private function run_field(AKSRT_AI $ai, string $context, string $system, string $field): array {
|
||
$result = $ai->generate_text($context, [
|
||
'system_instruction' => $system,
|
||
'max_tokens' => $field === 'description' ? 400 : 300,
|
||
'temperature' => 0.5,
|
||
]);
|
||
|
||
if (empty($result['success'])) {
|
||
return [
|
||
'error' => (string) ($result['message'] ?? __('生成失败', 'aksrt')),
|
||
];
|
||
}
|
||
|
||
$raw = (string) ($result['content'] ?? '');
|
||
return [
|
||
'content' => $this->post_process_field($field, $raw),
|
||
];
|
||
}
|
||
|
||
private function post_process_field(string $field, string $raw): string {
|
||
$raw = trim($raw);
|
||
// Strip common wrappers
|
||
$raw = preg_replace('/^["\'“”]+|["\'“”]+$/u', '', $raw) ?? $raw;
|
||
$raw = trim($raw);
|
||
|
||
if ($field === 'title') {
|
||
$title = sanitize_text_field($raw);
|
||
$seo = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_seo_settings() : [];
|
||
$conn = trim((string) ($seo['seo_connector'] ?? '-')) ?: '-';
|
||
$site = trim((string) get_bloginfo('name'));
|
||
if ($site !== '' && $title !== '' && mb_stripos($title, $site) === false) {
|
||
$title = $title . ' ' . $conn . ' ' . $site;
|
||
}
|
||
return $title;
|
||
}
|
||
|
||
if ($field === 'keywords') {
|
||
$raw = str_replace(['、', ',', ';', ';', "\n", "\r"], ',', $raw);
|
||
$parts = array_map('trim', explode(',', $raw));
|
||
$parts = array_values(array_unique(array_filter($parts, static function ($p) {
|
||
return $p !== '';
|
||
})));
|
||
$parts = array_map('sanitize_text_field', $parts);
|
||
return implode(',', array_slice($parts, 0, 12));
|
||
}
|
||
|
||
// description
|
||
$raw = preg_replace('/\s+/u', ' ', $raw) ?? $raw;
|
||
return sanitize_text_field(trim($raw));
|
||
}
|
||
}
|