array( 'post', 'page' ), 'post_status' => 'publish', 'posts_per_page' => $limit, 'no_found_rows' => true, 'orderby' => 'modified', 'order' => 'DESC', ) ); $out = array(); if ( $q->have_posts() ) { foreach ( $q->posts as $post ) { if ( 'noindex' === get_post_meta( $post->ID, '_yoast_wpseo_meta-robots-noindex', true ) ) { continue; } $out[] = $post; } } wp_reset_postdata(); return $out; } /** Build a curated llms.txt index from published content. */ public static function build_llms_txt() { $name = get_bloginfo( 'name' ); $desc = get_bloginfo( 'description' ); $out = "# {$name}\n\n"; if ( $desc ) { $out .= "> {$desc}\n\n"; } $out .= '# Full text: ' . home_url( '/llms-full.txt' ) . "\n\n"; $out .= "## Pages\n\n"; $docs = self::published_docs( 100 ); if ( $docs ) { foreach ( $docs as $post ) { $title = get_the_title( $post ); $url = get_permalink( $post ); $excerpt = wp_strip_all_tags( get_the_excerpt( $post ) ); $excerpt = $excerpt ? ': ' . wp_trim_words( $excerpt, 20, '' ) : ''; $out .= "- [{$title}]({$url}){$excerpt}\n"; } } else { // Demo fallback so the file is never empty on a fresh install. foreach ( AICitare_Data::seed_pages() as $p ) { $out .= "- [{$p['title']}]( " . home_url( $p['url'] ) . " )\n"; } } return $out; } /** * Build llms-full.txt — the expanded index that inlines each page's text so an * LLM can ingest the whole site in one fetch (the emerging "full" companion to llms.txt). */ public static function build_llms_full_txt() { $name = get_bloginfo( 'name' ); $desc = get_bloginfo( 'description' ); $out = "# {$name}\n\n"; if ( $desc ) { $out .= "> {$desc}\n\n"; } $docs = self::published_docs( 50 ); if ( ! $docs ) { $out .= "_No published content yet._\n"; return $out; } foreach ( $docs as $post ) { $title = get_the_title( $post ); $url = get_permalink( $post ); // do_shortcode (not the_content) avoids third-party content-filter fatals in this context. $body = wp_strip_all_tags( do_shortcode( $post->post_content ) ); $body = preg_replace( "/\n{3,}/", "\n\n", trim( $body ) ); if ( strlen( $body ) > 6000 ) { $body = substr( $body, 0, 6000 ) . "…"; } $out .= "\n---\n\n"; $out .= "## {$title}\n"; $out .= "URL: {$url}\n\n"; $out .= $body . "\n"; } return $out; } /** * Build ai.txt — a plain-text AI usage policy that mirrors the crawler toggles. * (Companion to robots.txt aimed specifically at AI/LLM agents.) */ public static function build_ai_txt() { $name = get_bloginfo( 'name' ); $bots = AICitare_Data::get_bots(); $meta = array(); foreach ( AICitare_Data::bot_meta() as $b ) { $meta[ $b['id'] ] = $b; } $out = "# ai.txt — AI usage policy for {$name}\n"; $out .= "# Generated by AICitare. Declares which AI agents may access this site's content.\n"; $out .= '# Index: ' . home_url( '/llms.txt' ) . "\n"; $out .= '# Full text: ' . home_url( '/llms-full.txt' ) . "\n\n"; foreach ( $bots as $bot => $allowed ) { $owner = isset( $meta[ $bot ]['owner'] ) ? $meta[ $bot ]['owner'] : ''; $out .= "# {$owner}\n"; $out .= "User-Agent: {$bot}\n"; $out .= ( $allowed ? "Allow: /\n\n" : "Disallow: /\n\n" ); } return $out; } /** Add allow/block directives for each AI crawler to robots.txt. */ public function robots_rules( $output, $public ) { $bots = AICitare_Data::get_bots(); $output .= "\n# --- AICitare: AI crawler management ---\n"; $output .= '# AI policy: ' . home_url( '/ai.txt' ) . "\n"; $output .= '# LLM index: ' . home_url( '/llms.txt' ) . "\n"; foreach ( $bots as $bot => $allowed ) { $output .= "\nUser-agent: {$bot}\n"; $output .= $allowed ? "Allow: /\n" : "Disallow: /\n"; } return $output; } /** On a front-end request, log an AI crawler hit or a human AI referral. */ public function log_traffic() { if ( is_admin() ) { return; } $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; $uri = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; // 1) AI crawler? if ( $ua ) { foreach ( AICitare_Data::bot_meta() as $b ) { if ( false !== stripos( $ua, $b['id'] ) ) { self::insert_hit( $b['id'], $uri, $ua ); return; } } } // 2) Human visitor arriving from an AI assistant (by referrer)? $ref = isset( $_SERVER['HTTP_REFERER'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : ''; if ( ! $ref ) { return; } $host = wp_parse_url( $ref, PHP_URL_HOST ); if ( ! $host || false !== stripos( $host, wp_parse_url( home_url(), PHP_URL_HOST ) ) ) { return; // ignore internal referrals } foreach ( AICitare_Data::ai_referrers() as $source => $needles ) { foreach ( $needles as $needle ) { if ( false !== stripos( $host, $needle ) ) { self::insert_referral( $source, $uri ); return; } } } } private static function insert_hit( $bot, $uri, $ua ) { global $wpdb; $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->prefix . 'aicitare_bot_hits', array( 'bot_name' => $bot, 'url' => $uri, 'user_agent' => $ua, 'ip' => isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '', 'hit_at' => current_time( 'mysql' ), ), array( '%s', '%s', '%s', '%s', '%s' ) ); } private static function insert_referral( $source, $uri ) { global $wpdb; $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->prefix . 'aicitare_ai_refs', array( 'source' => $source, 'url' => $uri, 'ref_at' => current_time( 'mysql' ), ), array( '%s', '%s', '%s' ) ); } /** Recent bot hits for the dashboard; falls back to demo data if empty. */ public static function recent_hits( $limit = 12 ) { global $wpdb; $table = $wpdb->prefix . 'aicitare_bot_hits'; $rows = $wpdb->get_results( $wpdb->prepare( "SELECT bot_name, url, hit_at FROM {$table} ORDER BY hit_at DESC LIMIT %d", $limit ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name is $wpdb->prefix + a hardcoded literal; the value is bound via prepare(). if ( empty( $rows ) ) { return AICitare_Data::bot_log(); } $bots = AICitare_Data::get_bots(); $out = array(); foreach ( $rows as $r ) { $out[] = array( 'bot' => $r['bot_name'], 'page' => $r['url'], 'time' => human_time_diff( strtotime( $r['hit_at'] ), time() ) . ' ago', 'ok' => ! empty( $bots[ $r['bot_name'] ] ), ); } return $out; } /** * Aggregate AI-crawler + AI-referral activity over the last $days days. * Falls back to a demo-derived summary when there is no real data yet and Demo mode is on. */ public static function bot_stats( $days = 30 ) { $days = max( 1, min( 365, (int) $days ) ); // Demo mode shows the sample dataset throughout the app; keep this view consistent. $settings = AICitare_Data::get_settings(); if ( ! empty( $settings['demo_mode'] ) ) { return self::demo_stats( $days ); } global $wpdb; $since = gmdate( 'Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS ); $hits = $wpdb->prefix . 'aicitare_bot_hits'; $refs = $wpdb->prefix . 'aicitare_ai_refs'; $by_bot = $wpdb->get_results( $wpdb->prepare( "SELECT bot_name, COUNT(*) c, MAX(hit_at) last_at FROM {$hits} WHERE hit_at >= %s GROUP BY bot_name ORDER BY c DESC", $since ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name is $wpdb->prefix + a hardcoded literal; the value is bound via prepare(). $top_pages = $wpdb->get_results( $wpdb->prepare( "SELECT url, COUNT(*) c FROM {$hits} WHERE hit_at >= %s GROUP BY url ORDER BY c DESC LIMIT 8", $since ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name is $wpdb->prefix + a hardcoded literal; the value is bound via prepare(). $timeline = $wpdb->get_results( $wpdb->prepare( "SELECT DATE(hit_at) d, COUNT(*) c FROM {$hits} WHERE hit_at >= %s GROUP BY DATE(hit_at) ORDER BY d", $since ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name is $wpdb->prefix + a hardcoded literal; the value is bound via prepare(). $ref_rows = $wpdb->get_results( $wpdb->prepare( "SELECT source, COUNT(*) c, MAX(ref_at) last_at FROM {$refs} WHERE ref_at >= %s GROUP BY source ORDER BY c DESC", $since ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name is $wpdb->prefix + a hardcoded literal; the value is bound via prepare(). $ref_recent = $wpdb->get_results( $wpdb->prepare( "SELECT source, url, ref_at FROM {$refs} WHERE ref_at >= %s ORDER BY ref_at DESC LIMIT 10", $since ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name is $wpdb->prefix + a hardcoded literal; the value is bound via prepare(). $bots = AICitare_Data::get_bots(); $meta = array(); foreach ( AICitare_Data::bot_meta() as $b ) { $meta[ $b['id'] ] = $b; } $crawlers = array(); $total = 0; foreach ( (array) $by_bot as $r ) { $total += (int) $r['c']; $crawlers[] = array( 'bot' => $r['bot_name'], 'owner' => isset( $meta[ $r['bot_name'] ]['owner'] ) ? $meta[ $r['bot_name'] ]['owner'] : '', 'count' => (int) $r['c'], 'allowed' => ! empty( $bots[ $r['bot_name'] ] ), 'last' => human_time_diff( strtotime( $r['last_at'] ), time() ) . ' ago', ); } $pages = array(); foreach ( (array) $top_pages as $r ) { $pages[] = array( 'url' => $r['url'], 'count' => (int) $r['c'] ); } $tl = array(); foreach ( (array) $timeline as $r ) { $tl[] = array( 'date' => $r['d'], 'count' => (int) $r['c'] ); } $ref_total = 0; $sources = array(); foreach ( (array) $ref_rows as $r ) { $ref_total += (int) $r['c']; $sources[] = array( 'source' => $r['source'], 'count' => (int) $r['c'], 'last' => human_time_diff( strtotime( $r['last_at'] ), time() ) . ' ago', ); } $recent = array(); foreach ( (array) $ref_recent as $r ) { $recent[] = array( 'source' => $r['source'], 'page' => $r['url'], 'time' => human_time_diff( strtotime( $r['ref_at'] ), time() ) . ' ago', ); } return array( 'days' => $days, 'demo' => false, 'totalCrawls' => $total, 'uniqueBots' => count( $crawlers ), 'crawlers' => $crawlers, 'topPages' => $pages, 'timeline' => $tl, 'referralTotal' => $ref_total, 'sources' => $sources, 'recentRefs' => $recent, ); } /** Demo-derived stats so the view is populated when Demo mode is on. Scales with the window. */ private static function demo_stats( $days ) { $bots = AICitare_Data::get_bots(); $meta = array(); foreach ( AICitare_Data::bot_meta() as $b ) { $meta[ $b['id'] ] = $b; } // Per-day averages; multiplied by the window so 7/30/90 differ visibly. $per_day = array( 'GPTBot' => 4.3, 'PerplexityBot' => 3.2, 'ClaudeBot' => 2.5, 'Google-Extended' => 2.0, 'OAI-SearchBot' => 1.5, 'Bytespider' => 0.7, 'CCBot' => 0.55, ); $crawlers = array(); $total = 0; foreach ( $per_day as $bot => $rate ) { $c = max( 1, (int) round( $rate * $days ) ); $total += $c; $crawlers[] = array( 'bot' => $bot, 'owner' => isset( $meta[ $bot ]['owner'] ) ? $meta[ $bot ]['owner'] : '', 'count' => $c, 'allowed' => ! empty( $bots[ $bot ] ), 'last' => ( $rate > 3 ? '18 min ago' : ( $rate > 1.4 ? '2 hr ago' : '9 hr ago' ) ), ); } $tl = array(); for ( $i = $days - 1; $i >= 0; $i-- ) { $tl[] = array( 'date' => gmdate( 'Y-m-d', time() - $i * DAY_IN_SECONDS ), 'count' => (int) round( 8 + 22 * ( 0.5 + 0.5 * sin( $i / 3.0 ) ) ), ); } $page_rates = array( '/best-cooling-pillows' => 2.4, '/memory-foam-vs-latex' => 2.0, '/side-sleeper-guide' => 1.7, '/neck-pain-pillows' => 1.3, '/casper-original-review' => 1.1, '/pillow-loft-guide' => 0.9, ); $pages = array(); foreach ( $page_rates as $u => $rate ) { $pages[] = array( 'url' => $u, 'count' => max( 1, (int) round( $rate * $days ) ) ); } $source_rates = array( 'ChatGPT' => 1.1, 'Perplexity' => 0.62, 'Gemini' => 0.37, 'Claude' => 0.23 ); $sources = array(); $ref_total = 0; $lasts = array( '22 min ago', '1 hr ago', '3 hr ago', '5 hr ago' ); $li = 0; foreach ( $source_rates as $src => $rate ) { $c = max( 1, (int) round( $rate * $days ) ); $ref_total += $c; $sources[] = array( 'source' => $src, 'count' => $c, 'last' => $lasts[ $li++ ] ); } $recent = array( array( 'source' => 'ChatGPT', 'page' => '/best-cooling-pillows', 'time' => '22 min ago' ), array( 'source' => 'Perplexity', 'page' => '/memory-foam-vs-latex', 'time' => '1 hr ago' ), array( 'source' => 'ChatGPT', 'page' => '/side-sleeper-guide', 'time' => '2 hr ago' ), array( 'source' => 'Gemini', 'page' => '/casper-original-review', 'time' => '3 hr ago' ), ); return array( 'days' => $days, 'demo' => true, 'totalCrawls' => $total, 'uniqueBots' => count( $crawlers ), 'crawlers' => $crawlers, 'topPages' => $pages, 'timeline' => $tl, 'referralTotal' => $ref_total, 'sources' => $sources, 'recentRefs' => $recent, ); } }