Incorrect timestamp in sitemap_index.xml
-
While looking into a site with SEO issues I noticed that the sitemap_index.xml was reporting lastmod timestamp for post-sitemap.xml that matched the latest mod timestamp for the entire site, instead of showing lastmod timestamp for the last post in that sitemap. That can’t be the intended setup, right? I can provide exports of the sitemap before and after applying a fix on request.
-
Hey @noboxjoakim,
Thanks for contacting the Yoast support team.
That is right. The timestamp for an individual sub-sitemap entry (like post-sitemap.xml) inside sitemap_index.xml should strictly reflect the most recent modification timestamp of the URLs contained within that specific sub-sitemap.
You may try turning off the sitemap feature, deactivating Yoast SEO and reactivating, clearing your site and server cache, and turning back on the sitemap feature to regenerate.
Also try to isolate the problem if there is another plugin causing the conflict.
@adminalje I tried deactivating and re-activating sitemaps within the settings with no luck. Below is the Claude generated analysis and fix that worked in the specific setup we had issues with. Perhaps not the most elegant fix but it did the trick then and there, time was of an essence.
——–
Report the real lastmod for page one of a post type sitemap
sitemap_index.xml claimed post-sitemap.xml had been modified today while the
file itself held posts last touched in 2017.Yoast renders every sitemap page with ORDER BY post_modified ASC, so page one
holds the least recently modified posts and should say so. But
WPSEO_Post_Type_Sitemap_Provider::get_index_links() picks the date like this:$current_page = ( $page_counter === 0 ) ? '' : ( $page_counter + 1 ); if ( empty( $current_page ) || $current_page === $max_pages ) { $date = $last_modified_times[ $post_type ]; }Page one’s $current_page is the empty string, so empty() sends it down the
branch intended for the last page and it receives MAX(post_modified_gmt) for
the whole post type. Page one then permanently advertises today’s date, and
Google re-fetches a file of untouched 2017 urls every time the index
regenerates – crawl budget spent daily on the oldest content on the site.Correct it through the supported wpseo_sitemap_index_links hook rather than
patching the plugin: for every post type that actually paginates, replace page
one’s lastmod with the modification date of the last row in its range. Post
types that fit on a single page are left alone, because there page one really
is the last page and Yoast’s value is already right. Taxonomy and author
sitemaps compute a real per-page date and are untouched.Reproduced on Yoast SEO 28.5.
——–
/**
* Correct the lastmod of page one of a paginated post type sitemap.
*
* Yoast renders each sitemap page with ORDER BY post_modified ASC, so page one holds
* the least recently modified posts. Its index entry should say so, but
* WPSEO_Post_Type_Sitemap_Provider::get_index_links() builds the date like this:
*
* $current_page = ( $page_counter === 0 ) ? '' : ( $page_counter + 1 );
* if ( empty( $current_page ) || $current_page === $max_pages ) {
* $date = $last_modified_times[ $post_type ];
* }
*
* Page one's $current_page is the empty string, so empty() sends it down the branch
* meant for the last page and it gets MAX(post_modified_gmt) for the whole post type.
* The result is that page one permanently advertises today's date while containing the
* oldest content on the site, and Google re-crawls it every time the index regenerates.
*
* Yoast only gets this right when there is a single page, which is also the last page,
* so the fix is limited to post types that actually paginate. Taxonomy and author
* sitemaps compute a real per-page date and are left alone.
*
* Upstream bug, reproduced on Yoast SEO 28.5.
*/
add_filter('wpseo_sitemap_index_links', function ($links) {
if (! is_array($links) || ! class_exists('WPSEO_Sitemaps')) {
return $links;
}
$entries_per_page = (int) apply_filters('wpseo_sitemap_entries_per_page', 1000);
if ($entries_per_page < 1) {
return $links;
}
foreach ($links as $index => $link) {
if (! isset($link['loc'], $link['lastmod'])) {
continue;
}
$post_type = clientname_sitemap_first_page_post_type($link['loc']);
if ($post_type === '') {
continue;
}
$lastmod = clientname_sitemap_first_page_lastmod($post_type, $entries_per_page);
if ($lastmod !== '') {
$links[$index]['lastmod'] = $lastmod;
}
}
return $links;
});
/**
* Read the post type out of the url of the first page of a post type sitemap.
*
* Yoast names the first page <post-type>-sitemap.xml and every page after it
* <post-type>-sitemap2.xml, so the absence of a trailing number identifies page one.
* Taxonomy and author sitemaps share the naming but not the post type, which is what
* the post_type_exists() check filters out.
*
* @param string $loc The url of the sitemap as it appears in the index.
*
* @return string The post type, or an empty string when this is not a post type page one.
*/
function clientname_sitemap_first_page_post_type(string $loc): string
{
$file = basename((string) parse_url($loc, PHP_URL_PATH));
if (! preg_match('/^(.+)-sitemap\.xml$/', $file, $matches)) {
return '';
}
return post_type_exists($matches[1]) ? $matches[1] : '';
}
/**
* Get the real lastmod of page one of a post type sitemap.
*
* Page one holds the $entries_per_page least recently modified posts, so the date to
* report is the one on the last row of that range.
*
* @param string $post_type The post type the sitemap covers.
* @param int $entries_per_page The number of entries Yoast puts on a sitemap page.
*
* @return string A gmt date string, or an empty string when the post type does not
* paginate and Yoast's own value is already correct.
*/
function clientname_sitemap_first_page_lastmod(string $post_type, int $entries_per_page): string
{
global $wpdb;
$statuses = WPSEO_Sitemaps::get_post_statuses($post_type);
if (empty($statuses)) {
return '';
}
$placeholders = implode(', ', array_fill(0, count($statuses), '%s'));
// An offset past the end returns nothing, which is the single page case: page one is
// also the last page and the date Yoast already set is the right one.
$lastmod = $wpdb->get_var(
$wpdb->prepare(
"SELECT post_modified_gmt FROM {$wpdb->posts}
WHERE post_status IN ({$placeholders}) AND post_type = %s
ORDER BY post_modified_gmt ASC
LIMIT 1 OFFSET %d",
array_merge($statuses, [$post_type, ($entries_per_page - 1)])
)
);
return is_string($lastmod) ? $lastmod : '';
}
You must be logged in to reply to this topic.