true ], 'names' ), $skip ) ); } /** * Post types actually checked "include in sitemap" in Settings → Sitemap, * limited to ones that still exist. * * @return string[] */ private static function get_configured_post_types() { $settings = get_option( 'awbseo_settings', AWBSEO_Core::default_settings() ); $types = (array) ( $settings['sitemap_post_types'] ?? [ 'post', 'page' ] ); return array_values( array_filter( $types, 'post_type_exists' ) ); } public static function add_query_vars( $vars ) { $vars[] = 'awb_sitemap'; return $vars; } public static function serve_sitemap() { $type = get_query_var( 'awb_sitemap' ); if ( ! $type ) return; $cached = get_transient( 'awbseo_sitemap_' . $type ); if ( $cached ) { self::output( $cached ); exit; } switch ( $type ) { case 'index': $xml = self::build_index(); break; case 'posts': $xml = self::build_posts(); break; case 'pages': $xml = self::build_pages(); break; case 'products': $xml = self::build_products(); break; case 'tax': $xml = self::build_taxonomies(); break; case 'images': $xml = self::build_images(); break; case 'news': $xml = self::build_news(); break; default: // Custom post type sub-sitemaps use a shared "cpt_{post_type}" // query var value (see register_rewrites()). $xml = ( 0 === strpos( $type, 'cpt_' ) ) ? self::build_cpt( substr( $type, 4 ) ) : null; break; } if ( $xml ) { set_transient( 'awbseo_sitemap_' . $type, $xml, HOUR_IN_SECONDS ); self::output( $xml ); } exit; } public static function flush_cache() { $types = [ 'index', 'posts', 'pages', 'products', 'tax', 'images', 'news' ]; foreach ( self::get_extra_post_types() as $pt ) { $types[] = 'cpt_' . $pt; } foreach ( $types as $t ) { delete_transient( 'awbseo_sitemap_' . $t ); } } /** * Return URL counts for each sub-sitemap for the admin preview page. * Uses fast COUNT queries - does not generate XML. * * @return array { * @type int posts Published posts (non-noindex) * @type int pages Published pages * @type int taxonomies Term URLs across configured taxonomies * @type int images Posts/pages that have at least one image in content * @type int news Posts published in the last 2 days * @type array cpt_counts Published count per included custom post type, keyed by slug * @type int total Grand total * } */ public static function get_url_counts() { global $wpdb; $settings = get_option( 'awbseo_settings', [] ); // Posts - exclude only those explicitly set to noindex. // Use IS NULL OR != noindex so posts with no meta row are included. $posts_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->posts} p LEFT JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND m.meta_key = '_awb_seo_robots_index' WHERE p.post_type = 'post' AND p.post_status = 'publish' AND ( m.meta_value IS NULL OR m.meta_value != 'noindex' )" ); // Pages. // Use WP function for accuracy (handles page exclusions etc.). $pages_count = (int) wp_count_posts( 'page' )->publish; // Taxonomy terms. $tax_count = 0; $configured_taxes = $settings['sitemap_taxonomies'] ?? [ 'category' ]; foreach ( $configured_taxes as $tax ) { $terms = get_terms( [ 'taxonomy' => $tax, 'hide_empty' => true, 'fields' => 'count' ] ); if ( ! is_wp_error( $terms ) ) { $tax_count += (int) $terms; } } // Images - posts/pages that contain at least one tag. $images_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type IN ('post','page') AND post_status = 'publish' AND post_content LIKE '%get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = 'post' AND post_status = 'publish' AND post_date_gmt >= %s", $two_days_ago ) ); // Custom post types checked "include in sitemap" in Settings → Sitemap // (post/page already counted above; product is WooCommerce's own). $cpt_counts = []; $configured = self::get_configured_post_types(); foreach ( self::get_extra_post_types() as $pt ) { if ( in_array( $pt, $configured, true ) ) { $cpt_counts[ $pt ] = (int) wp_count_posts( $pt )->publish; } } $cpt_total = array_sum( $cpt_counts ); $total = $posts_count + $pages_count + $tax_count + $images_count + $news_count + $cpt_total; return compact( 'posts_count', 'pages_count', 'tax_count', 'images_count', 'news_count', 'cpt_counts', 'total' ); } /** * Check whether the sitemap index is reachable (basic HTTP check). * * @return array { reachable: bool, code: int|null, latency_ms: int } */ public static function check_reachability() { $url = home_url( '/sitemap.xml' ); $start = microtime( true ); $resp = wp_remote_head( $url, [ 'timeout' => 5, 'sslverify' => false ] ); $ms = (int) round( ( microtime( true ) - $start ) * 1000 ); if ( is_wp_error( $resp ) ) { return [ 'reachable' => false, 'code' => null, 'latency_ms' => $ms ]; } $code = (int) wp_remote_retrieve_response_code( $resp ); return [ 'reachable' => $code >= 200 && $code < 400, 'code' => $code, 'latency_ms' => $ms ]; } private static function output( $xml ) { header( 'Content-Type: application/xml; charset=UTF-8' ); header( 'X-Robots-Tag: noindex, follow' ); echo $xml; } private static function header( $extra_ns = '' ) { return '' . "\n" . '\n"; } private static function footer() { return "\n"; } private static function build_index() { $sitemaps = [ home_url( '/sitemap-posts.xml' ), home_url( '/sitemap-pages.xml' ), home_url( '/sitemap-taxonomies.xml' ), home_url( '/sitemap-images.xml' ), home_url( '/sitemap-news.xml' ), ]; // Include products sub-sitemap only when WooCommerce is active. if ( post_type_exists( 'product' ) ) { $sitemaps[] = home_url( '/sitemap-products.xml' ); } // Include one sub-sitemap per additional custom post type that is // checked "include in sitemap" in Settings → Sitemap. $configured = self::get_configured_post_types(); foreach ( self::get_extra_post_types() as $pt ) { if ( in_array( $pt, $configured, true ) ) { $sitemaps[] = home_url( '/sitemap-' . $pt . '.xml' ); } } $xml = '' . "\n"; $xml .= '' . "\n"; foreach ( $sitemaps as $url ) { $xml .= " \n " . esc_url( $url ) . "\n \n"; } $xml .= ''; return $xml; } private static function build_posts() { // No meta_query filter here - posts often have no _awb_seo_robots_index // meta row at all, which would cause a != compare to exclude them. // build_url_set() already skips noindex posts in PHP. $posts = get_posts( [ 'post_type' => 'post', 'post_status' => 'publish', 'posts_per_page' => -1, ] ); return self::build_url_set( $posts ); } private static function build_pages() { $pages = get_posts( [ 'post_type' => 'page', 'post_status' => 'publish', 'posts_per_page' => -1, ] ); return self::build_url_set( $pages ); } private static function build_url_set( $posts, $with_images = false ) { $xml = self::header(); foreach ( $posts as $post ) { $robots = AWBSEO_Meta_Manager::get( $post->ID, 'robots_index', 'index' ); if ( $robots === 'noindex' ) continue; $xml .= " \n"; $xml .= ' ' . esc_url( get_permalink( $post->ID ) ) . "\n"; $xml .= ' ' . get_the_modified_date( 'c', $post->ID ) . "\n"; $xml .= " weekly\n"; $xml .= " 0.8\n"; if ( $with_images ) { preg_match_all( '/]+src=["\']([^"\']+)["\'][^>]*>/i', $post->post_content, $imgs ); foreach ( $imgs[1] as $img_url ) { $xml .= " \n"; $xml .= ' ' . esc_url( $img_url ) . "\n"; $xml .= " \n"; } } $xml .= " \n"; } return $xml . self::footer(); } private static function build_taxonomies() { $settings = get_option( 'awbseo_settings', AWBSEO_Core::default_settings() ); $xml = self::header(); foreach ( ( $settings['sitemap_taxonomies'] ?? [ 'category' ] ) as $tax ) { $terms = get_terms( [ 'taxonomy' => $tax, 'hide_empty' => true ] ); if ( is_wp_error( $terms ) ) continue; foreach ( $terms as $term ) { $xml .= " \n"; $xml .= ' ' . esc_url( get_term_link( $term ) ) . "\n"; $xml .= " weekly\n"; $xml .= " 0.6\n"; $xml .= " \n"; } } return $xml . self::footer(); } private static function build_products() { if ( ! post_type_exists( 'product' ) ) { return self::build_url_set( [] ); } // No meta_query filter here - products often have no _awb_seo_robots_index // meta row at all, which would cause a != compare to exclude them. // build_url_set() already skips noindex posts in PHP. $products = get_posts( [ 'post_type' => 'product', 'post_status' => 'publish', 'posts_per_page' => -1, ] ); return self::build_url_set( $products ); } /** * Sub-sitemap for one custom post type. Returns an empty (not * null) when the type is disabled/missing so a direct hit on the URL * behaves like any other empty sitemap rather than a hard failure. */ private static function build_cpt( $post_type ) { if ( ! post_type_exists( $post_type ) || ! in_array( $post_type, self::get_configured_post_types(), true ) ) { return self::build_url_set( [] ); } $posts = get_posts( [ 'post_type' => $post_type, 'post_status' => 'publish', 'posts_per_page' => -1, ] ); return self::build_url_set( $posts ); } private static function build_images() { $post_types = [ 'post', 'page' ]; if ( post_type_exists( 'product' ) ) { $post_types[] = 'product'; } $posts = get_posts( [ 'post_type' => $post_types, 'post_status' => 'publish', 'posts_per_page' => -1 ] ); return self::build_url_set( $posts, true ); } private static function build_news() { // Last 2 days for Google News. $posts = get_posts( [ 'post_type' => 'post', 'post_status' => 'publish', 'posts_per_page' => 1000, 'date_query' => [ [ 'after' => '2 days ago' ] ], ] ); $xml = '' . "\n" . '' . "\n"; foreach ( $posts as $post ) { $xml .= " \n"; $xml .= ' ' . esc_url( get_permalink( $post->ID ) ) . "\n"; $xml .= " \n"; $xml .= ' ' . esc_html( get_bloginfo( 'name' ) ) . "en\n"; $xml .= ' ' . get_the_date( 'c', $post->ID ) . "\n"; $xml .= ' ' . esc_html( get_the_title( $post->ID ) ) . "\n"; $xml .= " \n"; $xml .= " \n"; } $xml .= "\n"; return $xml; } }