wpmind/modules/geo/includes/AiIndexingManager.php
LinuxJoy ba4bdf20a9 feat: AI 索引指令功能 (noai/nollm meta + X-Robots-Tag + 内容声明)
新增 AiIndexingManager 类,支持全局和单篇控制 AI 爬虫索引权限:
- wp_head 输出 noai/nollm meta robots 和 ai-content-declaration meta
- send_headers 输出 X-Robots-Tag HTTP 头
- 编辑器侧边栏 metabox 支持单篇覆盖全局设置
- GEO 设置页新增 AI 索引指令 section(开关、默认声明、按 post type 排除)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-08 11:29:18 +08:00

332 lines
8.8 KiB
PHP

<?php
/**
* AI Indexing Manager
*
* Controls AI crawler indexing directives and content declarations.
*
* @package WPMind\Modules\Geo
* @since 3.9.0
*/
declare(strict_types=1);
namespace WPMind\Modules\Geo;
/**
* Class AiIndexingManager
*
* Manages noai/nollm meta robots, ai-content-declaration meta,
* X-Robots-Tag HTTP headers, and per-post metabox overrides.
*/
class AiIndexingManager {
/**
* Content declaration constants.
*/
public const DECLARATION_ORIGINAL = 'original';
public const DECLARATION_AI_ASSISTED = 'ai-assisted';
public const DECLARATION_AI_GENERATED = 'ai-generated';
/**
* Valid declarations.
*/
private const VALID_DECLARATIONS = [
self::DECLARATION_ORIGINAL,
self::DECLARATION_AI_ASSISTED,
self::DECLARATION_AI_GENERATED,
];
/**
* Post meta keys.
*/
private const META_NOINDEX = '_wpmind_ai_noindex';
private const META_NOLLM = '_wpmind_ai_nollm';
private const META_DECLARATION = '_wpmind_ai_declaration';
/**
* Nonce action for metabox.
*/
private const NONCE_ACTION = 'wpmind_ai_indexing_metabox';
/**
* Constructor.
*/
public function __construct() {
add_action( 'wp_head', [ $this, 'output_meta_tags' ], 2 );
add_action( 'send_headers', [ $this, 'output_http_headers' ] );
add_action( 'add_meta_boxes', [ $this, 'register_metabox' ] );
add_action( 'save_post', [ $this, 'save_metabox' ] );
}
/**
* Output AI indexing meta tags in wp_head.
*/
public function output_meta_tags(): void {
if ( ! is_singular() ) {
return;
}
$post = get_queried_object();
if ( ! $post instanceof \WP_Post ) {
return;
}
$excluded = $this->is_excluded( $post->ID );
if ( $excluded ) {
$directives = [];
if ( $this->has_noai( $post->ID ) ) {
$directives[] = 'noai';
}
if ( $this->has_nollm( $post->ID ) ) {
$directives[] = 'nollm';
}
if ( ! empty( $directives ) ) {
echo '<meta name="robots" content="' . esc_attr( implode( ', ', $directives ) ) . '">' . "\n";
}
}
$declaration = $this->get_declaration( $post->ID );
if ( ! empty( $declaration ) ) {
echo '<meta name="ai-content-declaration" content="' . esc_attr( $declaration ) . '">' . "\n";
}
}
/**
* Output X-Robots-Tag HTTP header.
*/
public function output_http_headers(): void {
if ( is_admin() || wp_doing_ajax() || wp_doing_cron() ) {
return;
}
if ( ! is_singular() ) {
return;
}
$post_id = get_queried_object_id();
if ( ! $post_id ) {
return;
}
if ( ! $this->is_excluded( $post_id ) ) {
return;
}
$directives = [];
if ( $this->has_noai( $post_id ) ) {
$directives[] = 'noai';
}
if ( $this->has_nollm( $post_id ) ) {
$directives[] = 'nollm';
}
if ( ! empty( $directives ) && ! headers_sent() ) {
header( 'X-Robots-Tag: ' . implode( ', ', $directives ) );
}
}
/**
* Register metabox on supported post types.
*/
public function register_metabox(): void {
$post_types = get_post_types( [ 'public' => true ], 'names' );
foreach ( $post_types as $post_type ) {
add_meta_box(
'wpmind-ai-indexing',
__( 'AI 索引指令', 'wpmind' ),
[ $this, 'render_metabox' ],
$post_type,
'side',
'default'
);
}
}
/**
* Render the metabox UI.
*
* @param \WP_Post $post Current post.
*/
public function render_metabox( \WP_Post $post ): void {
wp_nonce_field( self::NONCE_ACTION, '_wpmind_ai_indexing_nonce' );
$noindex = get_post_meta( $post->ID, self::META_NOINDEX, true ) === '1';
$nollm = get_post_meta( $post->ID, self::META_NOLLM, true ) === '1';
$declaration = get_post_meta( $post->ID, self::META_DECLARATION, true );
// Check if globally excluded by post type.
$excluded_types = get_option( 'wpmind_ai_excluded_post_types', [] );
if ( ! is_array( $excluded_types ) ) {
$excluded_types = [];
}
$globally_excluded = in_array( get_post_type( $post ), $excluded_types, true );
?>
<div class="wpmind-ai-indexing-metabox">
<?php if ( $globally_excluded ) : ?>
<p class="description" style="color:#d63638;margin-bottom:8px;">
<?php esc_html_e( '此内容类型已被全局排除 AI 索引。', 'wpmind' ); ?>
</p>
<?php endif; ?>
<p>
<label>
<input type="checkbox" name="wpmind_ai_noindex" value="1" <?php checked( $noindex ); ?>>
<?php esc_html_e( '排除 AI 索引 (noai)', 'wpmind' ); ?>
</label>
</p>
<p>
<label>
<input type="checkbox" name="wpmind_ai_nollm" value="1" <?php checked( $nollm ); ?>>
<?php esc_html_e( '排除 LLM 训练 (nollm)', 'wpmind' ); ?>
</label>
</p>
<p>
<label for="wpmind_ai_declaration"><?php esc_html_e( '内容声明:', 'wpmind' ); ?></label>
<select name="wpmind_ai_declaration" id="wpmind_ai_declaration" style="width:100%;margin-top:4px;">
<option value="" <?php selected( $declaration, '' ); ?>><?php esc_html_e( '使用全局默认', 'wpmind' ); ?></option>
<option value="original" <?php selected( $declaration, 'original' ); ?>><?php esc_html_e( '原创内容', 'wpmind' ); ?></option>
<option value="ai-assisted" <?php selected( $declaration, 'ai-assisted' ); ?>><?php esc_html_e( 'AI 辅助创作', 'wpmind' ); ?></option>
<option value="ai-generated" <?php selected( $declaration, 'ai-generated' ); ?>><?php esc_html_e( 'AI 生成内容', 'wpmind' ); ?></option>
</select>
</p>
</div>
<?php
}
/**
* Save metabox data.
*
* @param int $post_id Post ID.
*/
public function save_metabox( int $post_id ): void {
if ( ! isset( $_POST['_wpmind_ai_indexing_nonce'] ) ) {
return;
}
if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpmind_ai_indexing_nonce'] ) ), self::NONCE_ACTION ) ) {
return;
}
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
return;
}
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return;
}
// Save noai.
$noindex = isset( $_POST['wpmind_ai_noindex'] ) ? '1' : '0';
update_post_meta( $post_id, self::META_NOINDEX, $noindex );
// Save nollm.
$nollm = isset( $_POST['wpmind_ai_nollm'] ) ? '1' : '0';
update_post_meta( $post_id, self::META_NOLLM, $nollm );
// Save declaration.
$declaration = isset( $_POST['wpmind_ai_declaration'] )
? sanitize_key( wp_unslash( $_POST['wpmind_ai_declaration'] ) )
: '';
if ( '' !== $declaration && ! in_array( $declaration, self::VALID_DECLARATIONS, true ) ) {
$declaration = '';
}
update_post_meta( $post_id, self::META_DECLARATION, $declaration );
}
/**
* Check if a post is excluded from AI indexing.
*
* @param int $post_id Post ID.
* @return bool True if excluded.
*/
public function is_excluded( int $post_id ): bool {
// Check per-post meta.
if ( get_post_meta( $post_id, self::META_NOINDEX, true ) === '1' ) {
return true;
}
if ( get_post_meta( $post_id, self::META_NOLLM, true ) === '1' ) {
return true;
}
// Check global post type exclusion.
$excluded_types = get_option( 'wpmind_ai_excluded_post_types', [] );
if ( ! is_array( $excluded_types ) ) {
$excluded_types = [];
}
$post_type = get_post_type( $post_id );
if ( $post_type && in_array( $post_type, $excluded_types, true ) ) {
return true;
}
return false;
}
/**
* Get the content declaration for a post.
*
* Per-post override takes precedence over global default.
*
* @param int $post_id Post ID.
* @return string Declaration value.
*/
public function get_declaration( int $post_id ): string {
// Check per-post override.
$declaration = get_post_meta( $post_id, self::META_DECLARATION, true );
if ( ! empty( $declaration ) && in_array( $declaration, self::VALID_DECLARATIONS, true ) ) {
return $declaration;
}
// Fall back to global default.
return get_option( 'wpmind_ai_default_declaration', self::DECLARATION_ORIGINAL );
}
/**
* Check if post has noai directive.
*
* @param int $post_id Post ID.
* @return bool True if noai.
*/
private function has_noai( int $post_id ): bool {
// Per-post meta.
if ( get_post_meta( $post_id, self::META_NOINDEX, true ) === '1' ) {
return true;
}
// Global post type exclusion implies both noai and nollm.
$excluded_types = get_option( 'wpmind_ai_excluded_post_types', [] );
if ( ! is_array( $excluded_types ) ) {
return false;
}
$post_type = get_post_type( $post_id );
return $post_type && in_array( $post_type, $excluded_types, true );
}
/**
* Check if post has nollm directive.
*
* @param int $post_id Post ID.
* @return bool True if nollm.
*/
private function has_nollm( int $post_id ): bool {
// Per-post meta.
if ( get_post_meta( $post_id, self::META_NOLLM, true ) === '1' ) {
return true;
}
// Global post type exclusion implies both noai and nollm.
$excluded_types = get_option( 'wpmind_ai_excluded_post_types', [] );
if ( ! is_array( $excluded_types ) ) {
return false;
}
$post_type = get_post_type( $post_id );
return $post_type && in_array( $post_type, $excluded_types, true );
}
}