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;
}
}