feat(ai): 打通 SEO 闭环与投稿写作辅助
对齐 Zibll 的 AI 生成 SEO 流程:统一 WP AI Client/主题 Provider 双后端、文章/分类 SEO 框与生成回填、投稿摘要/标签建议,并收敛扩展页为连接配置。
This commit is contained in:
1 parent
a4cd5276e2
commit
b36186da65
14 files changed
+1243
-330
No files matched your search
@@ -1,8 +1,6 @@
|
||||
<?php
|
||||
/**
|
||||
* AKSRT AI SEO — AI SEO 辅助
|
||||
*
|
||||
* 提供 SEO 标题、描述、关键词建议。
|
||||
* AKSRT AI SEO — SEO 字段生成(对齐 Zibll 闭环)
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
@@ -21,91 +19,263 @@ class AKSRT_AI_SEO {
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 SEO 建议
|
||||
*
|
||||
* @param string $title 文章标题
|
||||
* @param string $content 文章内容(截取前2000字)
|
||||
* @return array{success: bool, data?: array, message?: string}
|
||||
* SEO AI 功能是否启用。
|
||||
*/
|
||||
public function suggest(string $title, string $content = ''): array {
|
||||
// seo_ai_enabled 主开关
|
||||
if (class_exists('AKSRT_Settings')) {
|
||||
$seo = AKSRT_Settings::get_seo_settings();
|
||||
if (empty($seo['seo_ai_enabled'])) {
|
||||
return ['success' => false, 'message' => 'AI SEO 功能未启用'];
|
||||
}
|
||||
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')) {
|
||||
return ['success' => false, 'message' => 'AI 模块未加载'];
|
||||
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 服务未启用'];
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => __('AI 服务未启用或未配置', 'aksrt'),
|
||||
];
|
||||
}
|
||||
|
||||
// 截取内容避免 token 超限
|
||||
$content = mb_substr($content, 0, 2000);
|
||||
|
||||
$prompt = AKSRT_AI_Prompt::get_instance()->build_seo_prompt($title, $content);
|
||||
|
||||
$result = $ai->generate($prompt, [
|
||||
'max_tokens' => 500,
|
||||
'temperature' => 0.5,
|
||||
]);
|
||||
|
||||
if (!$result['success']) {
|
||||
return $result;
|
||||
$type = $type === 'term' ? 'term' : 'post';
|
||||
if ($id <= 0) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => __('请先保存后再生成 SEO', 'aksrt'),
|
||||
];
|
||||
}
|
||||
|
||||
// 解析 JSON 响应
|
||||
$parsed = $this->parse_seo_response($result['content']);
|
||||
$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' => $parsed,
|
||||
'data' => [
|
||||
'title' => $title,
|
||||
'keywords' => $keywords,
|
||||
'description' => $description,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 SEO 响应
|
||||
* 草稿场景:用传入 title/content 生成(兼容旧 /ai/seo-suggest)。
|
||||
*/
|
||||
private function parse_seo_response(string $content): array {
|
||||
$defaults = [
|
||||
'suggested_title' => '',
|
||||
'suggested_description' => '',
|
||||
'suggested_keywords' => [],
|
||||
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,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// 尝试 JSON 解析
|
||||
$json = json_decode($content, true);
|
||||
if (is_array($json)) {
|
||||
return array_merge($defaults, $json);
|
||||
/**
|
||||
* @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'));
|
||||
}
|
||||
|
||||
// 尝试从文本中提取
|
||||
$lines = explode("\n", $content);
|
||||
$result = $defaults;
|
||||
$current_key = '';
|
||||
$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), '');
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if (empty($line)) continue;
|
||||
if ($title === '' && $content === '' && $excerpt === '') {
|
||||
return new WP_Error('empty_content', __('无可用内容,请先填写标题、摘要或正文并保存', 'aksrt'));
|
||||
}
|
||||
|
||||
if (stripos($line, '标题') !== false || stripos($line, 'title') !== false) {
|
||||
$current_key = 'suggested_title';
|
||||
$result[$current_key] = trim(preg_replace('/^[^::]+[::]\s*/', '', $line));
|
||||
} elseif (stripos($line, '描述') !== false || stripos($line, 'description') !== false) {
|
||||
$current_key = 'suggested_description';
|
||||
$result[$current_key] = trim(preg_replace('/^[^::]+[::]\s*/', '', $line));
|
||||
} elseif (stripos($line, '关键词') !== false || stripos($line, 'keywords') !== false) {
|
||||
$current_key = 'suggested_keywords';
|
||||
$keywords_str = trim(preg_replace('/^[^::]+[::]\s*/', '', $line));
|
||||
$result[$current_key] = array_map('trim', explode(',', $keywords_str));
|
||||
$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;
|
||||
}
|
||||
|
||||
return $result;
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user