Dateien nach „bavarian-rank-engine/includes/Features“ hochladen
This commit is contained in:
parent
0f8c5b2684
commit
dd3110b93e
5 changed files with 923 additions and 0 deletions
104
bavarian-rank-engine/includes/Features/CrawlerLog.php
Normal file
104
bavarian-rank-engine/includes/Features/CrawlerLog.php
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
<?php
|
||||||
|
namespace BavarianRankEngine\Features;
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CrawlerLog {
|
||||||
|
private const TABLE = 'bre_crawler_log';
|
||||||
|
private const CRON = 'bre_purge_crawler_log';
|
||||||
|
|
||||||
|
public static function install(): void {
|
||||||
|
global $wpdb;
|
||||||
|
$table = $wpdb->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
217
bavarian-rank-engine/includes/Features/LlmsTxt.php
Normal file
217
bavarian-rank-engine/includes/Features/LlmsTxt.php
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
<?php
|
||||||
|
namespace BavarianRankEngine\Features;
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LlmsTxt {
|
||||||
|
private const OPTION_KEY = 'bre_llms_settings';
|
||||||
|
private const CACHE_KEY = 'bre_llms_cache';
|
||||||
|
|
||||||
|
public function register(): void {
|
||||||
|
add_action( 'parse_request', array( $this, 'maybe_serve' ), 1 );
|
||||||
|
add_action( 'init', array( $this, 'add_rewrite_rule' ) );
|
||||||
|
add_filter( 'query_vars', array( $this, 'add_query_var' ) );
|
||||||
|
add_action( 'admin_notices', array( $this, 'rank_math_notice' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function maybe_serve(): void {
|
||||||
|
$uri = isset( $_SERVER['REQUEST_URI'] ) ? strtok( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ), '?' ) : '';
|
||||||
|
if ( $uri === '/llms.txt' ) {
|
||||||
|
$this->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 '<div class="notice notice-info is-dismissible"><p>'
|
||||||
|
. esc_html__( 'Bavarian Rank Engine bedient llms.txt mit Priorität — kein Handlungsbedarf bei Rank Math.', 'bavarian-rank-engine' )
|
||||||
|
. '</p></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
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() );
|
||||||
|
}
|
||||||
|
}
|
||||||
371
bavarian-rank-engine/includes/Features/MetaGenerator.php
Normal file
371
bavarian-rank-engine/includes/Features/MetaGenerator.php
Normal file
|
|
@ -0,0 +1,371 @@
|
||||||
|
<?php
|
||||||
|
namespace BavarianRankEngine\Features;
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
use BavarianRankEngine\Admin\SettingsPage;
|
||||||
|
use BavarianRankEngine\ProviderRegistry;
|
||||||
|
use BavarianRankEngine\Helpers\TokenEstimator;
|
||||||
|
use BavarianRankEngine\Helpers\BulkQueue;
|
||||||
|
use BavarianRankEngine\Helpers\FallbackMeta;
|
||||||
|
|
||||||
|
class MetaGenerator {
|
||||||
|
public function register(): void {
|
||||||
|
$settings = SettingsPage::getSettings();
|
||||||
|
|
||||||
|
if ( ! empty( $settings['meta_auto_enabled'] ) ) {
|
||||||
|
add_action( 'publish_post', array( $this, 'onPublish' ), 20, 2 );
|
||||||
|
add_action( 'publish_page', array( $this, 'onPublish' ), 20, 2 );
|
||||||
|
|
||||||
|
foreach ( $settings['meta_post_types'] as $post_type ) {
|
||||||
|
if ( ! in_array( $post_type, array( 'post', 'page' ), true ) ) {
|
||||||
|
add_action( "publish_{$post_type}", array( $this, 'onPublish' ), 20, 2 );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
add_action( 'wp_ajax_bre_bulk_generate', array( $this, 'ajaxBulkGenerate' ) );
|
||||||
|
add_action( 'wp_ajax_bre_bulk_stats', array( $this, 'ajaxBulkStats' ) );
|
||||||
|
add_action( 'wp_ajax_bre_bulk_release', array( $this, 'ajaxBulkRelease' ) );
|
||||||
|
add_action( 'wp_ajax_bre_bulk_status', array( $this, 'ajaxBulkStatus' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onPublish( int $post_id, \WP_Post $post ): void {
|
||||||
|
if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( $this->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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
bavarian-rank-engine/includes/Features/RobotsTxt.php
Normal file
51
bavarian-rank-engine/includes/Features/RobotsTxt.php
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
<?php
|
||||||
|
namespace BavarianRankEngine\Features;
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
class RobotsTxt {
|
||||||
|
private const OPTION_KEY = 'bre_robots_settings';
|
||||||
|
|
||||||
|
public const KNOWN_BOTS = array(
|
||||||
|
'GPTBot' => '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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
180
bavarian-rank-engine/includes/Features/SchemaEnhancer.php
Normal file
180
bavarian-rank-engine/includes/Features/SchemaEnhancer.php
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
<?php
|
||||||
|
namespace BavarianRankEngine\Features;
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
use BavarianRankEngine\Admin\SettingsPage;
|
||||||
|
|
||||||
|
class SchemaEnhancer {
|
||||||
|
public function register(): void {
|
||||||
|
$settings = SettingsPage::getSettings();
|
||||||
|
$enabled = $settings['schema_enabled'] ?? array();
|
||||||
|
|
||||||
|
if ( empty( $enabled ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( in_array( 'ai_meta_tags', $enabled, true ) ) {
|
||||||
|
add_action( 'wp_head', array( $this, 'outputAiMetaTags' ), 1 );
|
||||||
|
}
|
||||||
|
|
||||||
|
$json_ld_types = array_diff( $enabled, array( 'ai_meta_tags' ) );
|
||||||
|
if ( ! empty( $json_ld_types ) ) {
|
||||||
|
add_action( 'wp_head', array( $this, 'outputJsonLd' ), 5 );
|
||||||
|
}
|
||||||
|
|
||||||
|
add_action( 'wp_head', array( $this, 'outputMetaDescription' ), 2 );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function outputAiMetaTags(): void {
|
||||||
|
echo '<meta name="robots" content="max-snippet:-1, max-image-preview:large, max-video-preview:-1">' . "\n";
|
||||||
|
echo '<meta name="googlebot" content="max-snippet:-1, max-image-preview:large">' . "\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 '<meta name="description" content="' . esc_attr( $desc ) . '">' . "\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 '<script type="application/ld+json">'
|
||||||
|
. wp_json_encode( $schema, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES )
|
||||||
|
. '</script>' . "\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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue