$day, 'bot' => $bot['id'], 'path' => self::clean_path( $uri ), 'ip_prefix' => GetReport_Registry::ip_prefix( $ip ), 'verified' => -1 === $v ? self::UNVERIFIABLE : $v, 'status' => (int) $status, 'source' => $source, ); } /** Writes the pending hit: one query per request, after the response. */ public static function flush() { global $wpdb; if ( ! self::$pending ) { return; } $row = self::$pending; self::$pending = null; if ( ! $row['status'] ) { $row['status'] = (int) http_response_code(); } $wpdb->insert( GetReport_Db::table( 'bot_hits' ), $row, array( '%s', '%s', '%s', '%s', '%d', '%d', '%s' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery } /** * A request path as stored: no query string, no fragment, at most 191 characters. * * @param string $uri Request URI. */ public static function clean_path( $uri ) { $path = (string) strtok( (string) $uri, '?#' ); $path = '' === $path ? '/' : $path; if ( '/' !== $path[0] ) { $parsed = wp_parse_url( $path, PHP_URL_PATH ); $path = $parsed ? $parsed : '/'; } return substr( preg_replace( '/[\x00-\x1f\x7f]/', '', $path ), 0, 191 ); } /** * The client IP under the trusted-proxy rules (§3.4): REMOTE_ADDR; CF-Connecting-IP only when * REMOTE_ADDR is a Cloudflare address; X-Forwarded-For only from proxies the admin listed. * A forged header from anywhere else is ignored, so it cannot make a fake bot look verified. * * @param array $server $_SERVER. */ public static function client_ip( $server ) { $remote = isset( $server['REMOTE_ADDR'] ) ? trim( (string) $server['REMOTE_ADDR'] ) : ''; if ( '' === $remote || false === @inet_pton( $remote ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged return ''; } if ( ! empty( $server['HTTP_CF_CONNECTING_IP'] ) && self::is_cloudflare( $remote ) ) { $cf = trim( (string) $server['HTTP_CF_CONNECTING_IP'] ); return false !== @inet_pton( $cf ) ? $cf : $remote; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged } $trusted = self::trusted_proxies(); if ( ! empty( $server['HTTP_X_FORWARDED_FOR'] ) && self::in_any( $remote, $trusted ) ) { // The right-most address that is not one of our proxies is the client. $chain = array_reverse( array_map( 'trim', explode( ',', (string) $server['HTTP_X_FORWARDED_FOR'] ) ) ); foreach ( $chain as $hop ) { if ( false === @inet_pton( $hop ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged break; } if ( ! self::in_any( $hop, $trusted ) ) { return $hop; } } } return $remote; } /** * The trusted proxies from Settings → Crawlers (IPs or CIDR ranges, one per line). * * @return string[] */ public static function trusted_proxies() { $out = array(); foreach ( preg_split( '/[\s,]+/', (string) GetReport_Plugin::setting( 'trusted_proxies' ) ) as $p ) { if ( '' !== $p && false !== @inet_pton( strtok( $p, '/' ) ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged $out[] = $p; } } return $out; } /** * Whether an IP is in any of the ranges (a bare IP counts as its own range). * * @param string $ip IP. * @param string[] $ranges Ranges. */ private static function in_any( $ip, $ranges ) { foreach ( $ranges as $r ) { if ( GetReport_Registry::ip_in_cidr( $ip, $r ) ) { return true; } } return false; } /** * Whether an address belongs to Cloudflare (published ranges, bundled). * * @param string $ip IP. */ public static function is_cloudflare( $ip ) { $lists = GetReport_Registry::ip_ranges()['lists'] ?? array(); return self::in_any( $ip, array_merge( $lists['cloudflare_v4'] ?? array(), $lists['cloudflare_v6'] ?? array() ) ); } // ---- Roll-up and retention ---- /** * Daily maintenance: roll finished days up into bot_daily, then delete raw hits older than * the retention (default 90 days) and daily rows older than 24 months. */ public static function maintain() { global $wpdb; $hits = GetReport_Db::table( 'bot_hits' ); $daily = GetReport_Db::table( 'bot_daily' ); $today = current_time( 'Y-m-d' ); // phpcs:disable WordPress.DB.DirectDatabaseQuery -- the plugin's own tables $wpdb->query( $wpdb->prepare( 'INSERT INTO %i (day, bot, verified, hits, unique_paths) SELECT day, bot, verified, COUNT(*), COUNT(DISTINCT path) FROM %i WHERE day < %s GROUP BY day, bot, verified ON DUPLICATE KEY UPDATE hits = VALUES(hits), unique_paths = VALUES(unique_paths)', $daily, $hits, $today ) ); $keep = max( 7, (int) GetReport_Plugin::setting( 'retention_days' ) ); $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE day < %s', $hits, gmdate( 'Y-m-d', strtotime( $today . ' -' . $keep . ' days' ) ) ) ); $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE day < %s', $daily, gmdate( 'Y-m-d', strtotime( $today . ' -24 months' ) ) ) ); // phpcs:enable } // ---- Statistics ---- /** * Hits per day and bot over the last N days: finished days from the roll-up where the raw * hits are gone, raw hits otherwise. * * @param int $days Days, including today. * @param bool $verified_only Leave out hits whose IP was not in the bot's published ranges. * @return array> day → bot → hits. */ public static function series( $days, $verified_only = true ) { global $wpdb; $from = gmdate( 'Y-m-d', strtotime( current_time( 'Y-m-d' ) . ' -' . ( $days - 1 ) . ' days' ) ); $hits = GetReport_Db::table( 'bot_hits' ); $daily = GetReport_Db::table( 'bot_daily' ); $skip = $verified_only ? self::FAKE : -1; // -1 matches no row, so nothing is left out. // phpcs:disable WordPress.DB.DirectDatabaseQuery -- the plugin's own tables $raw = $wpdb->get_results( $wpdb->prepare( 'SELECT day, bot, COUNT(*) AS n FROM %i WHERE day >= %s AND verified <> %d GROUP BY day, bot', $hits, $from, $skip ), ARRAY_A ); $rolled = $wpdb->get_results( $wpdb->prepare( 'SELECT day, bot, SUM(hits) AS n FROM %i WHERE day >= %s AND verified <> %d GROUP BY day, bot', $daily, $from, $skip ), ARRAY_A ); $raw_min = $wpdb->get_var( $wpdb->prepare( 'SELECT MIN(day) FROM %i', $hits ) ); // phpcs:enable $out = array(); for ( $i = $days - 1; $i >= 0; $i-- ) { $out[ gmdate( 'Y-m-d', strtotime( current_time( 'Y-m-d' ) . ' -' . $i . ' days' ) ) ] = array(); } foreach ( (array) $rolled as $r ) { if ( $raw_min && $r['day'] >= $raw_min ) { continue; // The raw hits for this day are still there and more complete. } $out[ $r['day'] ][ $r['bot'] ] = (int) $r['n']; } foreach ( (array) $raw as $r ) { $out[ $r['day'] ][ $r['bot'] ] = (int) $r['n']; } return $out; } /** * Totals per bot over the last N days: verified, fake (outside the published ranges) and * unverifiable (the operator publishes no ranges), from the raw hits. * * @param int $days Days. * @return array */ public static function totals( $days ) { global $wpdb; $from = gmdate( 'Y-m-d', strtotime( current_time( 'Y-m-d' ) . ' -' . ( $days - 1 ) . ' days' ) ); $hits = GetReport_Db::table( 'bot_hits' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- the plugin's own table $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT bot, verified, COUNT(*) AS n, COUNT(DISTINCT path) AS p FROM %i WHERE day >= %s GROUP BY bot, verified', $hits, $from ), ARRAY_A ); $out = array(); foreach ( (array) $rows as $r ) { $t = $out[ $r['bot'] ] ?? array( 'verified' => 0, 'fake' => 0, 'unverifiable' => 0, 'paths' => 0, ); $k = array( self::FAKE => 'fake', self::VERIFIED => 'verified', self::UNVERIFIABLE => 'unverifiable', )[ (int) $r['verified'] ] ?? 'fake'; $t[ $k ] += (int) $r['n']; $t['paths'] = max( $t['paths'], (int) $r['p'] ); $out[ $r['bot'] ] = $t; } uasort( $out, function ( $a, $b ) { return ( $b['verified'] + $b['unverifiable'] ) - ( $a['verified'] + $a['unverifiable'] ); } ); return $out; } /** * The most-crawled paths over the last N days (fake hits left out). * * @param int $days Days. * @param string|null $bot Only this bot. * @param int $limit Rows. * @return array */ public static function top_paths( $days, $bot = null, $limit = 20 ) { global $wpdb; $from = gmdate( 'Y-m-d', strtotime( current_time( 'Y-m-d' ) . ' -' . ( $days - 1 ) . ' days' ) ); $hits = GetReport_Db::table( 'bot_hits' ); // phpcs:disable WordPress.DB.DirectDatabaseQuery -- the plugin's own table if ( $bot ) { $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT path, COUNT(*) AS n, bot AS bots FROM %i WHERE day >= %s AND verified <> 0 AND bot = %s GROUP BY path ORDER BY n DESC LIMIT %d', $hits, $from, $bot, $limit ), ARRAY_A ); } else { $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT path, COUNT(*) AS n, GROUP_CONCAT(DISTINCT bot) AS bots FROM %i WHERE day >= %s AND verified <> 0 GROUP BY path ORDER BY n DESC LIMIT %d', $hits, $from, $limit ), ARRAY_A ); } // phpcs:enable return (array) $rows; } /** * Hits answered with an error or a block (403, 429, 5xx) over the last N days, per bot. * * @param int $days Days. * @return array */ public static function blocked( $days ) { global $wpdb; $from = gmdate( 'Y-m-d', strtotime( current_time( 'Y-m-d' ) . ' -' . ( $days - 1 ) . ' days' ) ); $hits = GetReport_Db::table( 'bot_hits' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- the plugin's own table $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT bot, COUNT(*) AS n FROM %i WHERE day >= %s AND verified <> 0 AND (status IN (401, 403, 429) OR status >= 500) GROUP BY bot', $hits, $from ), ARRAY_A ); $out = array(); foreach ( (array) $rows as $r ) { $out[ $r['bot'] ] = (int) $r['n']; } return $out; } // ---- Server-log import (§3.4 b) ---- /** * Parses one line of a combined or common access log. Returns null for lines that are not * a known bot. The same trusted-proxy and path rules apply: the logged client address is * used as-is, since forwarded headers are not in the log. * * @param string $line Log line. * @return array|null Row for bot_hits. */ public static function parse_log_line( $line ) { if ( ! preg_match( '/^(\S+) \S+ \S+ \[([^\]]+)\] "(?:[A-Z]+) (\S+)[^"]*" (\d{3}) \S+(?: "[^"]*" "([^"]*)")?/', $line, $m ) ) { return null; } $bot = GetReport_Registry::match_bot( $m[5] ?? '' ); if ( ! $bot ) { return null; } $time = strtotime( str_replace( '/', ' ', preg_replace( '/:/', ' ', $m[2], 1 ) ) ); if ( ! $time ) { return null; } return self::hit_row( $bot, $m[1], $m[3], gmdate( 'Y-m-d', $time ), (int) $m[4], 'log' ); } /** * Imports an access log (an uploaded file or a readable path). Previous imported rows for * the same days are replaced, so importing the same log twice does not double the counts. * * @param string $path File path. * @return array{lines:int, hits:int, days:int}|WP_Error */ public static function import_log( $path ) { global $wpdb; if ( ! is_readable( $path ) ) { return new WP_Error( 'getreport_log', __( 'WordPress cannot read that file.', 'getreport' ) ); } // zlib reads plain and gzipped files alike (it passes plain text through), so the name of the // upload doesn't matter; without zlib, only plain logs can be read. $gz = function_exists( 'gzopen' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- streaming a large local file line by line $fh = $gz ? gzopen( $path, 'rb' ) : fopen( $path, 'rb' ); if ( ! $fh ) { return new WP_Error( 'getreport_log', __( 'WordPress cannot read that file.', 'getreport' ) ); } $rows = array(); $lines = 0; while ( $lines < 2000000 ) { $line = $gz ? gzgets( $fh ) : fgets( $fh ); if ( false === $line ) { break; } ++$lines; $row = self::parse_log_line( $line ); if ( $row ) { $rows[] = $row; } } $gz ? gzclose( $fh ) : fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose if ( ! $lines ) { return new WP_Error( 'getreport_log', __( 'The file is empty.', 'getreport' ) ); } $hits = GetReport_Db::table( 'bot_hits' ); $days = array_values( array_unique( array_column( $rows, 'day' ) ) ); foreach ( $days as $day ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- our own table $wpdb->delete( $hits, array( 'day' => $day, 'source' => 'log', ) ); } foreach ( array_chunk( $rows, 500 ) as $chunk ) { $args = array( $hits ); foreach ( $chunk as $r ) { array_push( $args, $r['day'], $r['bot'], $r['path'], $r['ip_prefix'], $r['verified'], $r['status'], 'log' ); } // One placeholder group per row, all filled by prepare(); WPCS cannot count placeholders made by array_fill(). // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- the plugin's own table; 1 + 7 per row $wpdb->query( $wpdb->prepare( 'INSERT INTO %i (day, bot, path, ip_prefix, verified, status, source) VALUES ' . implode( ', ', array_fill( 0, count( $chunk ), '(%s, %s, %s, %s, %d, %d, %s)' ) ), ...$args ) ); } return array( 'lines' => $lines, 'hits' => count( $rows ), 'days' => count( $days ), ); } // ---- What stands between the bots and the site ---- /** * Page-cache plugins active here: cached pages are served without running WordPress, so * those visits never reach the capture (§3.4 caveat). * * @return string[] Names. */ public static function page_caches() { $known = array( 'LiteSpeed Cache' => defined( 'LSCWP_V' ), 'WP Rocket' => defined( 'WP_ROCKET_VERSION' ), 'W3 Total Cache' => defined( 'W3TC' ), 'WP Super Cache' => defined( 'WPCACHEHOME' ), 'WP Fastest Cache' => defined( 'WPFC_WP_CONTENT_DIR' ) || class_exists( 'WpFastestCache' ), 'SiteGround Optimizer' => defined( 'SiteGround_Optimizer\VERSION' ) || class_exists( 'SiteGround_Optimizer\Loader\Loader' ), 'Breeze' => defined( 'BREEZE_VERSION' ), 'Hummingbird' => defined( 'WPHB_VERSION' ), 'Cache Enabler' => defined( 'CACHE_ENABLER_VERSION' ), 'NitroPack' => defined( 'NITROPACK_VERSION' ), 'Cloudflare (APO)' => defined( 'CLOUDFLARE_PLUGIN_DIR' ), ); return array_keys( array_filter( $known ) ); } /** * Security plugins and services that are known to block AI bots, with the exact setting to * check. Hints only: nothing is changed automatically (fix `firewall_hint`). * * @param bool $behind_cloudflare The current request came through Cloudflare. * @return array */ public static function firewall_hints( $behind_cloudflare ) { $out = array(); if ( $behind_cloudflare ) { $out[] = array( 'name' => 'Cloudflare', 'where' => __( 'Cloudflare dashboard → your domain → AI Crawl Control (older accounts: Security → Bots → "Block AI bots"). Allow the search and user bots (OAI-SearchBot, ChatGPT-User, PerplexityBot, Perplexity-User, Claude-SearchBot, Claude-User); blocking only the training bots is a business choice. Also check Security → WAF custom rules for rules that match "bot" user agents.', 'getreport' ), ); } if ( defined( 'WORDFENCE_VERSION' ) ) { $out[] = array( 'name' => 'Wordfence', 'where' => __( 'Wordfence → All Options → Rate Limiting: "If a crawler\'s page views exceed" set to Throttle, not Block. Also check Wordfence → Firewall → Blocking for user-agent patterns such as *bot*.', 'getreport' ), ); } if ( defined( 'SUCURISCAN_INIT' ) || defined( 'SUCURISCAN' ) ) { $out[] = array( 'name' => 'Sucuri', 'where' => __( 'Sucuri Firewall dashboard → Settings → Access Control → Blocked user agents: remove AI bot names you want to allow.', 'getreport' ), ); } if ( class_exists( 'ITSEC_Core' ) ) { $out[] = array( 'name' => 'Solid Security (iThemes)', 'where' => __( 'Security → Settings → Firewall → Banned users and user agents: remove AI bot names you want to allow.', 'getreport' ), ); } if ( defined( 'AIO_WP_SECURITY_VERSION' ) ) { $out[] = array( 'name' => 'All-In-One Security', 'where' => __( 'WP Security → Firewall → Blacklist / 6G rules: the 6G user-agent list can block bots; allow the AI search bots.', 'getreport' ), ); } return $out; } /** * Fetches the home page the way a bot would and the way a browser would, through the public * address (so a CDN or firewall in front is included), and compares the answers. * * @param string[] $tokens Bot user-agent tokens to try. * @return array */ public static function access_test( $tokens ) { $url = home_url( '/' ); $browser = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0 Safari/537.36'; $base = self::fetch_as( $url, $browser ); $out = array( 'browser' => $base ); foreach ( $tokens as $t ) { $ua = 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; ' . $t . '/1.0; +https://getreport.app/wordpress'; $res = self::fetch_as( $url, $ua ); if ( ! $res['blocked'] && $base['length'] > 0 && $res['length'] < $base['length'] * 0.7 ) { $res['blocked'] = true; $res['note'] = __( 'Much shorter page than browsers get', 'getreport' ); } $out[ $t ] = $res; } return $out; } /** * One fetch of the home page with a user agent. * * @param string $url URL. * @param string $ua User agent. * @return array{status:int, length:int, blocked:bool, note:string} */ private static function fetch_as( $url, $ua ) { $res = wp_safe_remote_get( $url, array( 'timeout' => 10, 'user-agent' => $ua, 'redirection' => 3, 'sslverify' => false, ) ); if ( is_wp_error( $res ) ) { return array( 'status' => 0, 'length' => 0, 'blocked' => true, 'note' => $res->get_error_message(), ); } $code = (int) wp_remote_retrieve_response_code( $res ); $body = (string) wp_remote_retrieve_body( $res ); $challenge = (bool) preg_match( '/cf-chl|challenge-platform|Just a moment\.\.\.|Attention Required|captcha-delivery|sgcaptcha/i', $body ); $note = ''; if ( $challenge ) { $note = __( 'Challenge page instead of your content', 'getreport' ); } elseif ( $code >= 400 ) { /* translators: %d: HTTP status code. */ $note = sprintf( __( 'Answered %d', 'getreport' ), $code ); } return array( 'status' => $code, 'length' => strlen( $body ), 'blocked' => $challenge || $code >= 400, 'note' => $note, ); } // ---- robots.txt ---- /** This site's robots.txt: the real file when there is one, else WordPress's virtual one. */ public static function robots_txt() { $file = ABSPATH . 'robots.txt'; if ( is_readable( $file ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- a local file return array( (string) file_get_contents( $file ), true ); } $public = (string) get_option( 'blog_public' ); $output = "User-agent: *\n"; if ( '0' === $public ) { $output .= "Disallow: /\n"; } else { // The same two lines as WordPress's own do_robots(), with the paths asked from WordPress. $output .= 'Disallow: ' . wp_parse_url( admin_url(), PHP_URL_PATH ) . "\n"; $output .= 'Allow: ' . wp_parse_url( admin_url( 'admin-ajax.php' ), PHP_URL_PATH ) . "\n"; } return array( (string) apply_filters( 'robots_txt', $output, $public ), false ); } /** * Whether robots.txt lets a user-agent token fetch a path (the group for the token, else * "*"; the longest matching rule wins, Allow wins a tie; * and $ wildcards). * * @param string $robots robots.txt. * @param string $token Token. * @param string $path Path. */ public static function robots_allows( $robots, $token, $path = '/' ) { $groups = array(); $current = array(); $in_ua = false; foreach ( preg_split( '/\r\n|\r|\n/', $robots ) as $raw ) { $line = trim( preg_replace( '/#.*$/', '', $raw ) ); if ( '' === $line || false === strpos( $line, ':' ) ) { continue; } list( $field, $value ) = array_map( 'trim', explode( ':', $line, 2 ) ); $field = strtolower( $field ); if ( 'user-agent' === $field ) { if ( ! $in_ua ) { $current = array(); } $current[] = strtolower( $value ); $in_ua = true; foreach ( $current as $ua ) { $groups[ $ua ] = $groups[ $ua ] ?? array(); } continue; } $in_ua = false; if ( 'allow' === $field || 'disallow' === $field ) { foreach ( $current as $ua ) { $groups[ $ua ][] = array( 'allow' === $field, $value ); } } } $rules = $groups[ strtolower( $token ) ] ?? ( $groups['*'] ?? array() ); $best = -1; $ok = true; foreach ( $rules as $r ) { list( $allow, $pattern ) = $r; if ( '' === $pattern ) { continue; // "Disallow:" with nothing allows everything. } $re = '#^' . str_replace( array( '\*', '\$' ), array( '.*', '$' ), preg_quote( $pattern, '#' ) ) . '#'; if ( preg_match( $re, $path ) ) { $len = strlen( $pattern ); if ( $len > $best || ( $len === $best && $allow ) ) { $best = $len; $ok = $allow; } } } return $ok; } }