diff --git a/bavarian-rank-engine/includes/Features/CrawlerLog.php b/bavarian-rank-engine/includes/Features/CrawlerLog.php
new file mode 100644
index 0000000..e91ec9c
--- /dev/null
+++ b/bavarian-rank-engine/includes/Features/CrawlerLog.php
@@ -0,0 +1,104 @@
+prefix . self::TABLE;
+ $charset = $wpdb->get_charset_collate();
+
+ $sql = "CREATE TABLE IF NOT EXISTS {$table} (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ bot_name VARCHAR(64) NOT NULL,
+ ip_hash VARCHAR(64) NOT NULL DEFAULT '',
+ url VARCHAR(512) NOT NULL DEFAULT '',
+ visited_at DATETIME NOT NULL,
+ PRIMARY KEY (id),
+ KEY bot_name (bot_name),
+ KEY visited_at (visited_at)
+ ) {$charset};";
+
+ require_once ABSPATH . 'wp-admin/includes/upgrade.php';
+ dbDelta( $sql );
+ }
+
+ public function register(): void {
+ add_action( 'init', array( $this, 'maybe_log' ), 1 );
+ add_action( self::CRON, array( $this, 'purge_old' ) );
+
+ if ( ! wp_next_scheduled( self::CRON ) ) {
+ wp_schedule_event( time(), 'weekly', self::CRON );
+ }
+ }
+
+ public function maybe_log(): void {
+ $ua = isset( $_SERVER['HTTP_USER_AGENT'] )
+ ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) )
+ : '';
+ if ( empty( $ua ) ) {
+ return;
+ }
+
+ $bot = $this->detect_bot( $ua );
+ if ( null === $bot ) {
+ return;
+ }
+
+ global $wpdb;
+ $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
+ $wpdb->prefix . self::TABLE,
+ array(
+ 'bot_name' => $bot,
+ 'ip_hash' => hash( 'sha256', isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '' ),
+ 'url' => mb_substr( isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '', 0, 512 ),
+ 'visited_at' => current_time( 'mysql' ),
+ ),
+ array( '%s', '%s', '%s', '%s' )
+ );
+ }
+
+ private function detect_bot( string $ua ): ?string {
+ foreach ( array_keys( RobotsTxt::KNOWN_BOTS ) as $bot ) {
+ if ( false !== stripos( $ua, $bot ) ) {
+ return $bot;
+ }
+ }
+ return null;
+ }
+
+ public function purge_old(): void {
+ global $wpdb;
+ $table = $wpdb->prefix . self::TABLE;
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
+ $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ "DELETE FROM `{$table}` WHERE visited_at < DATE_SUB(NOW(), INTERVAL 90 DAY)"
+ );
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
+ }
+
+ public static function get_recent_summary( int $days = 30 ): array {
+ global $wpdb;
+ if ( ! isset( $wpdb ) ) {
+ return array();
+ }
+ $table = $wpdb->prefix . self::TABLE;
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $sql = "SELECT bot_name, COUNT(*) as visits, MAX(visited_at) as last_seen
+ FROM `{$table}`
+ WHERE visited_at >= DATE_SUB(NOW(), INTERVAL %d DAY)
+ GROUP BY bot_name
+ ORDER BY visits DESC";
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ return $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->prepare( $sql, $days ), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+ ARRAY_A
+ ) ?: array();
+ }
+}
diff --git a/bavarian-rank-engine/includes/Features/LlmsTxt.php b/bavarian-rank-engine/includes/Features/LlmsTxt.php
new file mode 100644
index 0000000..1d83cc5
--- /dev/null
+++ b/bavarian-rank-engine/includes/Features/LlmsTxt.php
@@ -0,0 +1,217 @@
+serve_page( 1 );
+ return;
+ }
+ if ( preg_match( '#^/llms-(\d+)\.txt$#', $uri, $m ) ) {
+ $this->serve_page( (int) $m[1] );
+ }
+ }
+
+ public function rank_math_notice(): void {
+ if ( ! defined( 'RANK_MATH_VERSION' ) ) {
+ return;
+ }
+ $settings = self::getSettings();
+ if ( empty( $settings['enabled'] ) ) {
+ return;
+ }
+ echo '
'
+ . esc_html__( 'Bavarian Rank Engine bedient llms.txt mit Priorität — kein Handlungsbedarf bei Rank Math.', 'bavarian-rank-engine' )
+ . '
';
+ }
+
+ public function add_rewrite_rule(): void {
+ add_rewrite_rule( '^llms\.txt$', 'index.php?bre_llms=1', 'top' );
+ }
+
+ public function add_query_var( array $vars ): array {
+ $vars[] = 'bre_llms';
+ return $vars;
+ }
+
+ private function serve_page( int $page ): void {
+ $settings = self::getSettings();
+ if ( empty( $settings['enabled'] ) ) {
+ status_header( 404 );
+ exit;
+ }
+
+ $cache_key = self::CACHE_KEY . '_p' . $page;
+ $cached = get_transient( $cache_key );
+
+ if ( $cached === false ) {
+ $cached = $this->build( $settings, $page );
+ set_transient( $cache_key, $cached, 0 );
+ }
+
+ $etag = '"' . md5( $cached ) . '"';
+ $last_modified = $this->get_last_modified();
+
+ header( 'Content-Type: text/plain; charset=utf-8' );
+ header( 'ETag: ' . $etag );
+ header( 'Last-Modified: ' . gmdate( 'D, d M Y H:i:s', $last_modified ) . ' GMT' );
+ header( 'Cache-Control: public, max-age=3600' );
+
+ $if_none_match = isset( $_SERVER['HTTP_IF_NONE_MATCH'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_NONE_MATCH'] ) ) : '';
+ if ( $if_none_match === $etag ) {
+ status_header( 304 );
+ exit;
+ }
+
+ // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ echo $cached;
+ exit;
+ }
+
+ private function get_last_modified(): int {
+ global $wpdb;
+ if ( ! isset( $wpdb ) ) {
+ return time();
+ }
+ $latest = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ "SELECT UNIX_TIMESTAMP(MAX(post_modified_gmt)) FROM {$wpdb->posts}
+ WHERE post_status = 'publish'"
+ );
+ return $latest ? (int) $latest : time();
+ }
+
+ public static function clear_cache(): void {
+ global $wpdb;
+ if ( ! isset( $wpdb ) ) {
+ return;
+ }
+ $wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_bre_llms_cache%'" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared
+ $wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_timeout_bre_llms_cache%'" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared
+ }
+
+ private function build( array $s, int $page = 1 ): string {
+ $max_links = max( 50, (int) ( $s['max_links'] ?? 500 ) );
+ $post_types = $s['post_types'] ?? array( 'post', 'page' );
+ $all_posts = $this->get_all_posts( $post_types );
+ $total = count( $all_posts );
+ $pages = $total > 0 ? (int) ceil( $total / $max_links ) : 1;
+ $offset = ( $page - 1 ) * $max_links;
+ $page_posts = array_slice( $all_posts, $offset, $max_links );
+
+ $out = '';
+
+ if ( $page === 1 ) {
+ if ( ! empty( $s['title'] ) ) {
+ $out .= '# ' . $s['title'] . "\n\n";
+ }
+ if ( ! empty( $s['description_before'] ) ) {
+ $out .= trim( $s['description_before'] ) . "\n\n";
+ }
+ if ( ! empty( $s['custom_links'] ) ) {
+ $out .= "## Featured Resources\n\n";
+ foreach ( explode( "\n", trim( $s['custom_links'] ) ) as $line ) {
+ $line = trim( $line );
+ if ( $line !== '' ) {
+ $out .= $line . "\n";
+ }
+ }
+ $out .= "\n";
+ }
+ }
+
+ if ( ! empty( $page_posts ) ) {
+ $out .= "## Content\n\n";
+ foreach ( $page_posts as $post ) {
+ $out .= sprintf(
+ '- [%s](%s) — %s',
+ $post->post_title,
+ get_permalink( $post ),
+ get_the_date( 'Y-m-d', $post )
+ ) . "\n";
+ }
+ $out .= "\n";
+ }
+
+ if ( $pages > 1 ) {
+ $out .= "## More\n\n";
+ for ( $p = 1; $p <= $pages; $p++ ) {
+ if ( $p === $page ) {
+ continue;
+ }
+ $filename = $p === 1 ? 'llms.txt' : "llms-{$p}.txt";
+ $url = home_url( '/' . $filename );
+ $out .= "- [{$filename}]({$url})\n";
+ }
+ $out .= "\n";
+ }
+
+ if ( $page === 1 ) {
+ if ( ! empty( $s['description_after'] ) ) {
+ $out .= "\n---\n" . trim( $s['description_after'] ) . "\n";
+ }
+ if ( ! empty( $s['description_footer'] ) ) {
+ $out .= "\n---\n" . trim( $s['description_footer'] ) . "\n";
+ }
+ }
+
+ return $out;
+ }
+
+ private function get_all_posts( array $post_types ): array {
+ if ( empty( $post_types ) ) {
+ return array();
+ }
+ $query = new \WP_Query(
+ array(
+ 'post_type' => $post_types,
+ 'post_status' => 'publish',
+ 'posts_per_page' => -1,
+ 'orderby' => 'date',
+ 'order' => 'DESC',
+ 'no_found_rows' => true,
+ )
+ );
+ $posts = $query->posts;
+ wp_reset_postdata();
+ return $posts;
+ }
+
+ /**
+ * Flush rewrite rules on activation.
+ * Call this from your activation hook.
+ */
+ public function flush_rules(): void {
+ $this->add_rewrite_rule();
+ flush_rewrite_rules();
+ }
+
+ public static function getSettings(): array {
+ $defaults = array(
+ 'enabled' => false,
+ 'title' => '',
+ 'description_before' => '',
+ 'description_after' => '',
+ 'description_footer' => '',
+ 'custom_links' => '',
+ 'post_types' => array( 'post', 'page' ),
+ 'max_links' => 500,
+ );
+ $saved = get_option( self::OPTION_KEY, array() );
+ return array_merge( $defaults, is_array( $saved ) ? $saved : array() );
+ }
+}
diff --git a/bavarian-rank-engine/includes/Features/MetaGenerator.php b/bavarian-rank-engine/includes/Features/MetaGenerator.php
new file mode 100644
index 0000000..fac7404
--- /dev/null
+++ b/bavarian-rank-engine/includes/Features/MetaGenerator.php
@@ -0,0 +1,371 @@
+hasExistingMeta( $post_id ) ) {
+ return;
+ }
+
+ $settings = SettingsPage::getSettings();
+ if ( ! in_array( $post->post_type, $settings['meta_post_types'], true ) ) {
+ return;
+ }
+
+ try {
+ $api_key = $settings['api_keys'][ $settings['provider'] ] ?? '';
+ $source = ! empty( $api_key ) ? 'ai' : 'fallback';
+ $description = $this->generate( $post, $settings );
+ if ( ! empty( $description ) ) {
+ $this->saveMeta( $post_id, $description, $source );
+ }
+ } catch ( \Exception $e ) {
+ if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
+ // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
+ error_log( '[BRE] Meta generation failed for post ' . $post_id . ': ' . $e->getMessage() );
+ }
+ // Try fallback
+ $fallback = FallbackMeta::extract( $post );
+ if ( $fallback !== '' ) {
+ $this->saveMeta( $post_id, $fallback, 'fallback' );
+ }
+ }
+ }
+
+ public function generate( \WP_Post $post, array $settings ): string {
+ $registry = ProviderRegistry::instance();
+ $provider = $registry->get( $settings['provider'] );
+ $api_key = $settings['api_keys'][ $settings['provider'] ] ?? '';
+
+ // No provider or no API key → use fallback immediately
+ if ( ! $provider || empty( $api_key ) ) {
+ return FallbackMeta::extract( $post );
+ }
+
+ $model = $settings['models'][ $settings['provider'] ] ?? array_key_first( $provider->getModels() );
+ $content = $this->prepareContent( $post, $settings );
+ $prompt = $this->buildPrompt( $post, $content, $settings );
+
+ return $provider->generateText( $prompt, $api_key, $model, 300 );
+ }
+
+ private function prepareContent( \WP_Post $post, array $settings ): string {
+ $content = wp_strip_all_tags( $post->post_content );
+ if ( $settings['token_mode'] === 'limit' ) {
+ $content = TokenEstimator::truncate( $content, (int) $settings['token_limit'] );
+ }
+ return $content;
+ }
+
+ private function buildPrompt( \WP_Post $post, string $content, array $settings ): string {
+ $language = $this->detectLanguage( $post );
+ $prompt = $settings['prompt'];
+
+ $prompt = str_replace( '{title}', $post->post_title, $prompt );
+ $prompt = str_replace( '{content}', $content, $prompt );
+ $prompt = str_replace( '{excerpt}', $post->post_excerpt ?: '', $prompt );
+ $prompt = str_replace( '{language}', $language, $prompt );
+
+ return apply_filters( 'bre_prompt', $prompt, $post ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
+ }
+
+ private function detectLanguage( \WP_Post $post ): string {
+ if ( function_exists( 'pll_get_post_language' ) ) {
+ $lang = pll_get_post_language( $post->ID, 'name' );
+ if ( $lang ) {
+ return $lang;
+ }
+ }
+
+ if ( defined( 'ICL_LANGUAGE_CODE' ) ) {
+ return ICL_LANGUAGE_CODE;
+ }
+
+ $locale_map = array(
+ 'de_DE' => 'Deutsch',
+ 'de_AT' => 'Deutsch',
+ 'de_CH' => 'Deutsch',
+ 'en_US' => 'English',
+ 'en_GB' => 'English',
+ 'fr_FR' => 'Français',
+ 'es_ES' => 'Español',
+ );
+
+ return $locale_map[ get_locale() ] ?? 'Deutsch';
+ }
+
+ public function hasExistingMeta( int $post_id ): bool {
+ $fields = array(
+ '_bre_meta_description',
+ 'rank_math_description',
+ '_yoast_wpseo_metadesc',
+ '_aioseo_description',
+ '_seopress_titles_desc',
+ '_meta_description',
+ );
+ foreach ( $fields as $field ) {
+ if ( ! empty( get_post_meta( $post_id, $field, true ) ) ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public function saveMeta( int $post_id, string $description, string $source = 'ai' ): void {
+ $clean = sanitize_text_field( $description );
+ update_post_meta( $post_id, '_bre_meta_source', sanitize_key( $source ) );
+ update_post_meta( $post_id, '_bre_meta_description', $clean );
+
+ if ( defined( 'RANK_MATH_VERSION' ) ) {
+ update_post_meta( $post_id, 'rank_math_description', $clean );
+ } elseif ( defined( 'WPSEO_VERSION' ) ) {
+ update_post_meta( $post_id, '_yoast_wpseo_metadesc', $clean );
+ } elseif ( defined( 'AIOSEO_VERSION' ) ) {
+ update_post_meta( $post_id, '_aioseo_description', $clean );
+ } elseif ( class_exists( 'SeoPress_Titles_Admin' ) ) {
+ update_post_meta( $post_id, '_seopress_titles_desc', $clean );
+ }
+
+ do_action( 'bre_meta_saved', $post_id, $description ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
+ }
+
+ public function ajaxBulkStats(): void {
+ check_ajax_referer( 'bre_admin', 'nonce' );
+ if ( ! current_user_can( 'manage_options' ) ) {
+ wp_send_json_error( __( 'Insufficient permissions.', 'bavarian-rank-engine' ) );
+ }
+
+ $settings = SettingsPage::getSettings();
+ $stats = array();
+
+ foreach ( $settings['meta_post_types'] as $pt ) {
+ $stats[ $pt ] = $this->countPostsWithoutMeta( $pt );
+ }
+
+ wp_send_json_success( $stats );
+ }
+
+ public function ajaxBulkRelease(): void {
+ check_ajax_referer( 'bre_admin', 'nonce' );
+ if ( ! current_user_can( 'manage_options' ) ) {
+ wp_send_json_error();
+ }
+ BulkQueue::release();
+ wp_send_json_success();
+ }
+
+ public function ajaxBulkStatus(): void {
+ check_ajax_referer( 'bre_admin', 'nonce' );
+ if ( ! current_user_can( 'manage_options' ) ) {
+ wp_send_json_error();
+ }
+ wp_send_json_success(
+ array(
+ 'locked' => BulkQueue::isLocked(),
+ 'lock_age' => BulkQueue::lockAge(),
+ )
+ );
+ }
+
+ public function ajaxBulkGenerate(): void {
+ check_ajax_referer( 'bre_admin', 'nonce' );
+ if ( ! current_user_can( 'manage_options' ) ) {
+ wp_send_json_error( __( 'Insufficient permissions.', 'bavarian-rank-engine' ) );
+ }
+
+ // Acquire lock on first batch
+ if ( ! empty( $_POST['is_first'] ) ) {
+ if ( ! BulkQueue::acquire() ) {
+ wp_send_json_error(
+ array(
+ 'locked' => true,
+ 'lock_age' => BulkQueue::lockAge(),
+ 'message' => __( 'Ein Bulk-Prozess läuft bereits.', 'bavarian-rank-engine' ),
+ )
+ );
+ return;
+ }
+ }
+
+ $post_type = sanitize_key( wp_unslash( $_POST['post_type'] ?? 'post' ) );
+ $limit = min( 20, max( 1, absint( wp_unslash( $_POST['batch_size'] ?? 5 ) ) ) );
+ $settings = SettingsPage::getSettings();
+
+ if ( ! empty( $_POST['provider'] ) ) {
+ $settings['provider'] = sanitize_key( wp_unslash( $_POST['provider'] ) );
+ }
+ if ( ! empty( $_POST['model'] ) ) {
+ $provider_obj = ProviderRegistry::instance()->get( $settings['provider'] );
+ $allowed_models = $provider_obj ? array_keys( $provider_obj->getModels() ) : array();
+ $requested_model = sanitize_text_field( wp_unslash( $_POST['model'] ) );
+ if ( in_array( $requested_model, $allowed_models, true ) ) {
+ $settings['models'][ $settings['provider'] ] = $requested_model;
+ }
+ }
+
+ $post_ids = $this->getPostsWithoutMeta( $post_type, $limit );
+ $results = array();
+ $max_retries = 3;
+
+ foreach ( $post_ids as $post_id ) {
+ $post = get_post( $post_id );
+ $success = false;
+ $last_error = '';
+
+ for ( $attempt = 1; $attempt <= $max_retries; $attempt++ ) {
+ try {
+ $desc = $this->generate( $post, $settings );
+ $this->saveMeta( $post_id, $desc );
+ delete_post_meta( $post_id, '_bre_bulk_failed' );
+ $results[] = array(
+ 'id' => $post_id,
+ 'title' => get_the_title( $post_id ),
+ 'description' => $desc,
+ 'success' => true,
+ 'attempts' => $attempt,
+ );
+ $success = true;
+ break;
+ } catch ( \Exception $e ) {
+ $last_error = $e->getMessage();
+ if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
+ // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
+ error_log( '[BRE] Post ' . $post_id . ' attempt ' . $attempt . '/' . $max_retries . ': ' . $last_error );
+ }
+ if ( $attempt < $max_retries ) {
+ sleep( 1 );
+ }
+ }
+ }
+
+ if ( ! $success ) {
+ update_post_meta( $post_id, '_bre_bulk_failed', $last_error );
+ $results[] = array(
+ 'id' => $post_id,
+ 'title' => get_the_title( $post_id ),
+ 'error' => $last_error,
+ 'success' => false,
+ );
+ }
+ }
+
+ // Release lock when JS signals last batch
+ if ( ! empty( $_POST['is_last'] ) ) {
+ BulkQueue::release();
+ }
+
+ wp_send_json_success(
+ array(
+ 'results' => $results,
+ 'processed' => count( $results ),
+ 'remaining' => $this->countPostsWithoutMeta( $post_type ),
+ 'locked' => BulkQueue::isLocked(),
+ )
+ );
+ }
+
+ private function countPostsWithoutMeta( string $post_type ): int {
+ global $wpdb;
+
+ $meta_fields = array(
+ '_bre_meta_description',
+ 'rank_math_description',
+ '_yoast_wpseo_metadesc',
+ '_aioseo_description',
+ '_seopress_titles_desc',
+ '_meta_description',
+ );
+
+ $not_exists = '';
+ foreach ( $meta_fields as $field ) {
+ $not_exists .= $wpdb->prepare(
+ " AND NOT EXISTS (
+ SELECT 1 FROM {$wpdb->postmeta} pm
+ WHERE pm.post_id = p.ID AND pm.meta_key = %s AND pm.meta_value != ''
+ )",
+ $field
+ );
+ }
+
+ return (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter
+ $wpdb->prepare(
+ "SELECT COUNT(*) FROM {$wpdb->posts} p
+ WHERE p.post_type = %s AND p.post_status = 'publish'" . $not_exists, // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+ $post_type
+ )
+ );
+ }
+
+ private function getPostsWithoutMeta( string $post_type, int $limit ): array {
+ global $wpdb;
+
+ $meta_fields = array(
+ '_bre_meta_description',
+ 'rank_math_description',
+ '_yoast_wpseo_metadesc',
+ '_aioseo_description',
+ '_seopress_titles_desc',
+ '_meta_description',
+ );
+
+ $not_exists = '';
+ foreach ( $meta_fields as $field ) {
+ $not_exists .= $wpdb->prepare(
+ " AND NOT EXISTS (
+ SELECT 1 FROM {$wpdb->postmeta} pm
+ WHERE pm.post_id = p.ID
+ AND pm.meta_key = %s
+ AND pm.meta_value != ''
+ )",
+ $field
+ );
+ }
+
+ return array_map(
+ 'intval',
+ $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter
+ $wpdb->prepare(
+ "SELECT p.ID FROM {$wpdb->posts} p
+ WHERE p.post_type = %s AND p.post_status = 'publish'"
+ . $not_exists . // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+ ' ORDER BY p.ID DESC LIMIT %d',
+ $post_type,
+ $limit
+ )
+ )
+ );
+ }
+}
diff --git a/bavarian-rank-engine/includes/Features/RobotsTxt.php b/bavarian-rank-engine/includes/Features/RobotsTxt.php
new file mode 100644
index 0000000..3361f3f
--- /dev/null
+++ b/bavarian-rank-engine/includes/Features/RobotsTxt.php
@@ -0,0 +1,51 @@
+ 'OpenAI GPTBot',
+ 'ClaudeBot' => 'Anthropic ClaudeBot',
+ 'Google-Extended' => 'Google Extended (Bard/Gemini Training)',
+ 'PerplexityBot' => 'Perplexity AI',
+ 'CCBot' => 'Common Crawl (CCBot)',
+ 'Applebot-Extended' => 'Apple AI (Applebot-Extended)',
+ 'Bytespider' => 'ByteDance Bytespider',
+ 'DataForSeoBot' => 'DataForSEO Bot',
+ 'ImagesiftBot' => 'Imagesift Bot',
+ 'omgili' => 'Omgili Bot',
+ 'Diffbot' => 'Diffbot',
+ 'FacebookBot' => 'Meta FacebookBot',
+ 'Amazonbot' => 'Amazon Amazonbot',
+ );
+
+ public function register(): void {
+ add_filter( 'robots_txt', array( $this, 'append_rules' ), 20, 2 );
+ }
+
+ public function append_rules( string $output, bool $public ): string {
+ $settings = self::getSettings();
+ $blocked = $settings['blocked_bots'] ?? array();
+
+ foreach ( $blocked as $bot ) {
+ if ( isset( self::KNOWN_BOTS[ $bot ] ) ) {
+ $output .= "\nUser-agent: {$bot}\nDisallow: /\n";
+ }
+ }
+
+ return $output;
+ }
+
+ public static function getSettings(): array {
+ $saved = get_option( self::OPTION_KEY, array() );
+ return array_merge(
+ array( 'blocked_bots' => array() ),
+ is_array( $saved ) ? $saved : array()
+ );
+ }
+}
diff --git a/bavarian-rank-engine/includes/Features/SchemaEnhancer.php b/bavarian-rank-engine/includes/Features/SchemaEnhancer.php
new file mode 100644
index 0000000..5756c8d
--- /dev/null
+++ b/bavarian-rank-engine/includes/Features/SchemaEnhancer.php
@@ -0,0 +1,180 @@
+' . "\n";
+ echo '' . "\n";
+ }
+
+ public function outputMetaDescription(): void {
+ if ( defined( 'RANK_MATH_VERSION' ) || defined( 'WPSEO_VERSION' ) || defined( 'AIOSEO_VERSION' ) ) {
+ return;
+ }
+ if ( ! is_singular() ) {
+ return;
+ }
+
+ $desc = get_post_meta( get_the_ID(), '_bre_meta_description', true );
+ if ( empty( $desc ) ) {
+ return;
+ }
+
+ echo '' . "\n";
+ }
+
+ public function outputJsonLd(): void {
+ $settings = SettingsPage::getSettings();
+ $enabled = $settings['schema_enabled'] ?? array();
+ $schemas = array();
+
+ if ( in_array( 'organization', $enabled, true ) ) {
+ $schemas[] = $this->buildOrganizationSchema( $settings );
+ }
+
+ if ( is_singular() ) {
+ if ( in_array( 'article_about', $enabled, true ) ) {
+ $schemas[] = $this->buildArticleSchema();
+ }
+ if ( in_array( 'author', $enabled, true ) ) {
+ $schemas[] = $this->buildAuthorSchema();
+ }
+ if ( in_array( 'speakable', $enabled, true ) ) {
+ $schemas[] = $this->buildSpeakableSchema();
+ }
+ }
+
+ if ( in_array( 'breadcrumb', $enabled, true )
+ && ! defined( 'RANK_MATH_VERSION' )
+ && ! defined( 'WPSEO_VERSION' ) ) {
+ $breadcrumb = $this->buildBreadcrumbSchema();
+ if ( $breadcrumb ) {
+ $schemas[] = $breadcrumb;
+ }
+ }
+
+ foreach ( $schemas as $schema ) {
+ echo '' . "\n";
+ }
+ }
+
+ private function buildOrganizationSchema( array $settings ): array {
+ $same_as = array_values( array_filter( $settings['schema_same_as']['organization'] ?? array() ) );
+ $schema = array(
+ '@context' => 'https://schema.org',
+ '@type' => 'Organization',
+ 'name' => get_bloginfo( 'name' ),
+ 'url' => home_url( '/' ),
+ );
+ if ( ! empty( $same_as ) ) {
+ $schema['sameAs'] = $same_as;
+ }
+ $logo = get_site_icon_url( 192 );
+ if ( $logo ) {
+ $schema['logo'] = $logo;
+ }
+ return $schema;
+ }
+
+ private function buildArticleSchema(): array {
+ return array(
+ '@context' => 'https://schema.org',
+ '@type' => 'Article',
+ 'headline' => get_the_title(),
+ 'url' => get_permalink(),
+ 'datePublished' => get_the_date( 'c' ),
+ 'dateModified' => get_the_modified_date( 'c' ),
+ 'description' => get_post_meta( get_the_ID(), '_bre_meta_description', true ) ?: get_the_excerpt(),
+ 'publisher' => array(
+ '@type' => 'Organization',
+ 'name' => get_bloginfo( 'name' ),
+ 'url' => home_url( '/' ),
+ ),
+ );
+ }
+
+ private function buildAuthorSchema(): array {
+ $author_id = (int) get_the_author_meta( 'ID' );
+ $schema = array(
+ '@context' => 'https://schema.org',
+ '@type' => 'Person',
+ 'name' => get_the_author(),
+ 'url' => get_author_posts_url( $author_id ),
+ );
+ $twitter = get_the_author_meta( 'twitter', $author_id );
+ if ( $twitter ) {
+ $schema['sameAs'] = array( 'https://twitter.com/' . ltrim( $twitter, '@' ) );
+ }
+ return $schema;
+ }
+
+ private function buildSpeakableSchema(): array {
+ return array(
+ '@context' => 'https://schema.org',
+ '@type' => 'WebPage',
+ 'url' => get_permalink(),
+ 'speakable' => array(
+ '@type' => 'SpeakableSpecification',
+ 'cssSelector' => array( 'h1', '.entry-content p:first-of-type', '.post-content p:first-of-type' ),
+ ),
+ );
+ }
+
+ private function buildBreadcrumbSchema(): ?array {
+ if ( ! is_singular() && ! is_category() ) {
+ return null;
+ }
+
+ $items = array(
+ array(
+ '@type' => 'ListItem',
+ 'position' => 1,
+ 'name' => get_bloginfo( 'name' ),
+ 'item' => home_url( '/' ),
+ ),
+ );
+
+ if ( is_singular() ) {
+ $items[] = array(
+ '@type' => 'ListItem',
+ 'position' => 2,
+ 'name' => get_the_title(),
+ 'item' => get_permalink(),
+ );
+ }
+
+ return array(
+ '@context' => 'https://schema.org',
+ '@type' => 'BreadcrumbList',
+ 'itemListElement' => $items,
+ );
+ }
+}