PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / 27.5
Yoast SEO – Advanced SEO with real-time guidance and built-in AI v27.5
28.5 28.4 28.3 28.2 28.1 28.0 27.9 27.8 27.7 27.6 27.5 trunk 18.0 18.1 18.2 18.3 18.4 18.4.1 18.5 18.5.1 18.6 18.7 18.8 18.9 19.0 All 129 releases
wordpress-seo / inc / sitemaps / class-post-type-sitemap-provider.php

class-post-type-sitemap-provider.php in Yoast SEO – Advanced SEO with real-time guidance and built-in AI 27.5, at inc/sitemaps/class-post-type-sitemap-provider.php

767 lines 19.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WPSEO plugin file.
4 *
5 * @package WPSEO\XML_Sitemaps
6 */
7
8 use Yoast\WP\SEO\Models\SEO_Links;
9
10 /**
11 * Sitemap provider for author archives.
12 */
13 class WPSEO_Post_Type_Sitemap_Provider implements WPSEO_Sitemap_Provider {
14
15 /**
16 * Holds image parser instance.
17 *
18 * @var WPSEO_Sitemap_Image_Parser
19 */
20 protected static $image_parser;
21
22 /**
23 * Holds the parsed home url.
24 *
25 * @var array
26 */
27 protected static $parsed_home_url;
28
29 /**
30 * Determines whether images should be included in the XML sitemap.
31 *
32 * @var bool
33 */
34 private $include_images;
35
36 /**
37 * Set up object properties for data reuse.
38 */
39 public function __construct() {
40 add_action( 'save_post', [ $this, 'save_post' ] );
41
42 /**
43 * Filter - Allows excluding images from the XML sitemap.
44 *
45 * @param bool $include True to include, false to exclude.
46 */
47 $this->include_images = apply_filters( 'wpseo_xml_sitemap_include_images', true );
48 }
49
50 /**
51 * Get the Image Parser.
52 *
53 * @return WPSEO_Sitemap_Image_Parser
54 */
55 protected function get_image_parser() {
56 if ( ! isset( self::$image_parser ) ) {
57 self::$image_parser = new WPSEO_Sitemap_Image_Parser();
58 }
59
60 return self::$image_parser;
61 }
62
63 /**
64 * Gets the parsed home url.
65 *
66 * @return array The home url, as parsed by wp_parse_url.
67 */
68 protected function get_parsed_home_url() {
69 if ( ! isset( self::$parsed_home_url ) ) {
70 self::$parsed_home_url = wp_parse_url( home_url() );
71 }
72
73 return self::$parsed_home_url;
74 }
75
76 /**
77 * Check if provider supports given item type.
78 *
79 * @param string $type Type string to check for.
80 *
81 * @return bool
82 */
83 public function handles_type( $type ) {
84
85 return post_type_exists( $type );
86 }
87
88 /**
89 * Retrieves the sitemap links.
90 *
91 * @param int $max_entries Entries per sitemap.
92 *
93 * @return array
94 */
95 public function get_index_links( $max_entries ) {
96 global $wpdb;
97 $post_types = WPSEO_Post_Type::get_accessible_post_types();
98 $post_types = array_filter( $post_types, [ $this, 'is_valid_post_type' ] );
99 $last_modified_times = WPSEO_Sitemaps::get_last_modified_gmt( $post_types, true );
100 $index = [];
101
102 foreach ( $post_types as $post_type ) {
103
104 $total_count = $this->get_post_type_count( $post_type );
105
106 if ( $total_count === 0 ) {
107 continue;
108 }
109
110 $max_pages = 1;
111 if ( $total_count > $max_entries ) {
112 $max_pages = (int) ceil( $total_count / $max_entries );
113 }
114
115 $all_dates = [];
116
117 if ( $max_pages > 1 ) {
118 $all_dates = version_compare( $wpdb->db_version(), '8.0', '>=' ) ? $this->get_all_dates_using_with_clause( $post_type, $max_entries ) : $this->get_all_dates( $post_type, $max_entries );
119 }
120
121 for ( $page_counter = 0; $page_counter < $max_pages; $page_counter++ ) {
122
123 $current_page = ( $page_counter === 0 ) ? '' : ( $page_counter + 1 );
124 $date = false;
125
126 if ( empty( $current_page ) || $current_page === $max_pages ) {
127
128 if ( ! empty( $last_modified_times[ $post_type ] ) ) {
129 $date = $last_modified_times[ $post_type ];
130 }
131 }
132 else {
133 $date = $all_dates[ $page_counter ];
134 }
135
136 $index[] = [
137 'loc' => WPSEO_Sitemaps_Router::get_base_url( $post_type . '-sitemap' . $current_page . '.xml' ),
138 'lastmod' => $date,
139 ];
140 }
141 }
142
143 return $index;
144 }
145
146 /**
147 * Get set of sitemap link data.
148 *
149 * @param string $type Sitemap type.
150 * @param int $max_entries Entries per sitemap.
151 * @param int $current_page Current page of the sitemap.
152 *
153 * @return array
154 *
155 * @throws OutOfBoundsException When an invalid page is requested.
156 */
157 public function get_sitemap_links( $type, $max_entries, $current_page ) {
158
159 $links = [];
160 $post_type = $type;
161
162 if ( ! $this->is_valid_post_type( $post_type ) ) {
163 throw new OutOfBoundsException( 'Invalid sitemap page requested' );
164 }
165
166 $steps = min( 100, $max_entries );
167 $offset = ( $current_page > 1 ) ? ( ( $current_page - 1 ) * $max_entries ) : 0;
168 $total = ( $offset + $max_entries );
169
170 $post_type_entries = $this->get_post_type_count( $post_type );
171
172 if ( $total > $post_type_entries ) {
173 $total = $post_type_entries;
174 }
175
176 if ( $current_page === 1 ) {
177 $links = array_merge( $links, $this->get_first_links( $post_type ) );
178 }
179
180 // If total post type count is lower than the offset, an invalid page is requested.
181 if ( $post_type_entries < $offset ) {
182 throw new OutOfBoundsException( 'Invalid sitemap page requested' );
183 }
184
185 if ( $post_type_entries === 0 ) {
186 return $links;
187 }
188
189 $posts_to_exclude = $this->get_excluded_posts( $type );
190
191 while ( $total > $offset ) {
192
193 $posts = $this->get_posts( $post_type, $steps, $offset );
194
195 $offset += $steps;
196
197 if ( empty( $posts ) ) {
198 continue;
199 }
200
201 foreach ( $posts as $post ) {
202
203 if ( in_array( $post->ID, $posts_to_exclude, true ) ) {
204 continue;
205 }
206
207 if ( WPSEO_Meta::get_value( 'meta-robots-noindex', $post->ID ) === '1' ) {
208 continue;
209 }
210
211 $url = $this->get_url( $post );
212
213 if ( ! isset( $url['loc'] ) ) {
214 continue;
215 }
216
217 /**
218 * Filter URL entry before it gets added to the sitemap.
219 *
220 * @param array $url Array of URL parts.
221 * @param string $type URL type.
222 * @param object $post Data object for the URL.
223 */
224 $url = apply_filters( 'wpseo_sitemap_entry', $url, 'post', $post );
225
226 if ( ! empty( $url ) ) {
227 $links[] = $url;
228 }
229 }
230
231 unset( $post, $url );
232 }
233
234 return $links;
235 }
236
237 /**
238 * Check for relevant post type before invalidation.
239 *
240 * @param int $post_id Post ID to possibly invalidate for.
241 *
242 * @return void
243 */
244 public function save_post( $post_id ) {
245
246 if ( $this->is_valid_post_type( get_post_type( $post_id ) ) ) {
247 WPSEO_Sitemaps_Cache::invalidate_post( $post_id );
248 }
249 }
250
251 /**
252 * Check if post type should be present in sitemaps.
253 *
254 * @param string $post_type Post type string to check for.
255 *
256 * @return bool
257 */
258 public function is_valid_post_type( $post_type ) {
259 if ( ! WPSEO_Post_Type::is_post_type_accessible( $post_type ) || ! WPSEO_Post_Type::is_post_type_indexable( $post_type ) ) {
260 return false;
261 }
262
263 /**
264 * Filter decision if post type is excluded from the XML sitemap.
265 *
266 * @param bool $exclude Default false.
267 * @param string $post_type Post type name.
268 */
269 if ( apply_filters( 'wpseo_sitemap_exclude_post_type', false, $post_type ) ) {
270 return false;
271 }
272
273 return true;
274 }
275
276 /**
277 * Retrieves a list with the excluded post ids.
278 *
279 * @param string $post_type Post type.
280 *
281 * @return array Array with post ids to exclude.
282 */
283 protected function get_excluded_posts( $post_type ) {
284 $excluded_posts_ids = [];
285
286 $page_on_front_id = ( $post_type === 'page' ) ? (int) get_option( 'page_on_front' ) : 0;
287 if ( $page_on_front_id > 0 ) {
288 $excluded_posts_ids[] = $page_on_front_id;
289 }
290
291 /**
292 * Filter: 'wpseo_exclude_from_sitemap_by_post_ids' - Allow extending and modifying the posts to exclude.
293 *
294 * @param array $posts_to_exclude The posts to exclude.
295 */
296 $excluded_posts_ids = apply_filters( 'wpseo_exclude_from_sitemap_by_post_ids', $excluded_posts_ids );
297 if ( ! is_array( $excluded_posts_ids ) ) {
298 $excluded_posts_ids = [];
299 }
300
301 $excluded_posts_ids = array_map( 'intval', $excluded_posts_ids );
302
303 $page_for_posts_id = ( $post_type === 'page' ) ? (int) get_option( 'page_for_posts' ) : 0;
304 if ( $page_for_posts_id > 0 ) {
305 $excluded_posts_ids[] = $page_for_posts_id;
306 }
307
308 return array_unique( $excluded_posts_ids );
309 }
310
311 /**
312 * Get count of posts for post type.
313 *
314 * @param string $post_type Post type to retrieve count for.
315 *
316 * @return int
317 */
318 protected function get_post_type_count( $post_type ) {
319
320 global $wpdb;
321
322 /**
323 * Filter JOIN query part for type count of post type.
324 *
325 * @param string $join SQL part, defaults to empty string.
326 * @param string $post_type Post type name.
327 */
328 $join_filter = apply_filters( 'wpseo_typecount_join', '', $post_type );
329
330 /**
331 * Filter WHERE query part for type count of post type.
332 *
333 * @param string $where SQL part, defaults to empty string.
334 * @param string $post_type Post type name.
335 */
336 $where_filter = apply_filters( 'wpseo_typecount_where', '', $post_type );
337
338 $where = $this->get_sql_where_clause( $post_type );
339
340 $sql = "
341 SELECT COUNT({$wpdb->posts}.ID)
342 FROM {$wpdb->posts}
343 {$join_filter}
344 {$where}
345 {$where_filter}
346 ";
347
348 return (int) $wpdb->get_var( $sql );
349 }
350
351 /**
352 * Produces set of links to prepend at start of first sitemap page.
353 *
354 * @param string $post_type Post type to produce links for.
355 *
356 * @return array
357 */
358 protected function get_first_links( $post_type ) {
359
360 $links = [];
361 $archive_url = false;
362
363 if ( $post_type === 'page' ) {
364
365 $page_on_front_id = (int) get_option( 'page_on_front' );
366 if ( $page_on_front_id > 0 ) {
367 $front_page = $this->get_url(
368 get_post( $page_on_front_id ),
369 );
370 }
371
372 if ( empty( $front_page ) ) {
373 $front_page = [
374 'loc' => YoastSEO()->helpers->url->home(),
375 ];
376 }
377
378 // Deprecated, kept for backwards data compat. R.
379 $front_page['chf'] = 'daily';
380 $front_page['pri'] = 1;
381
382 $images = ( $front_page['images'] ?? [] );
383
384 /**
385 * Filter images to be included for the term in XML sitemap.
386 *
387 * @param array $images Array of image items.
388 * @return array $image_list Array of image items.
389 */
390 $image_list = apply_filters( 'wpseo_sitemap_urlimages_front_page', $images );
391 if ( is_array( $image_list ) ) {
392 $front_page['images'] = $image_list;
393 }
394
395 $links[] = $front_page;
396 }
397 elseif ( $post_type !== 'page' ) {
398 /**
399 * Filter the URL Yoast SEO uses in the XML sitemap for this post type archive.
400 *
401 * @param string $archive_url The URL of this archive
402 * @param string $post_type The post type this archive is for.
403 */
404 $archive_url = apply_filters(
405 'wpseo_sitemap_post_type_archive_link',
406 $this->get_post_type_archive_link( $post_type ),
407 $post_type,
408 );
409 }
410
411 if ( $archive_url ) {
412
413 $links[] = [
414 'loc' => $archive_url,
415 'mod' => WPSEO_Sitemaps::get_last_modified_gmt( $post_type ),
416
417 // Deprecated, kept for backwards data compat. R.
418 'chf' => 'daily',
419 'pri' => 1,
420 ];
421 }
422
423 /**
424 * Filters the first post type links.
425 *
426 * @param array $links The first post type links.
427 * @param string $post_type The post type this archive is for.
428 */
429 return apply_filters( 'wpseo_sitemap_post_type_first_links', $links, $post_type );
430 }
431
432 /**
433 * Get URL for a post type archive.
434 *
435 * @since 5.3
436 *
437 * @param string $post_type Post type.
438 *
439 * @return string|bool URL or false if it should be excluded.
440 */
441 protected function get_post_type_archive_link( $post_type ) {
442
443 $pt_archive_page_id = -1;
444
445 if ( $post_type === 'post' ) {
446
447 if ( get_option( 'show_on_front' ) === 'posts' ) {
448 return YoastSEO()->helpers->url->home();
449 }
450
451 $pt_archive_page_id = (int) get_option( 'page_for_posts' );
452
453 // Post archive should be excluded if posts page isn't set.
454 if ( $pt_archive_page_id <= 0 ) {
455 return false;
456 }
457 }
458
459 if ( ! $this->is_post_type_archive_indexable( $post_type, $pt_archive_page_id ) ) {
460 return false;
461 }
462
463 return get_post_type_archive_link( $post_type );
464 }
465
466 /**
467 * Determines whether a post type archive is indexable.
468 *
469 * @since 11.5
470 *
471 * @param string $post_type Post type.
472 * @param int $archive_page_id The page id.
473 *
474 * @return bool True when post type archive is indexable.
475 */
476 protected function is_post_type_archive_indexable( $post_type, $archive_page_id = -1 ) {
477
478 if ( WPSEO_Options::get( 'noindex-ptarchive-' . $post_type, false ) ) {
479 return false;
480 }
481
482 /**
483 * Filter the page which is dedicated to this post type archive.
484 *
485 * @since 9.3
486 *
487 * @param string $archive_page_id The post_id of the page.
488 * @param string $post_type The post type this archive is for.
489 */
490 $archive_page_id = (int) apply_filters( 'wpseo_sitemap_page_for_post_type_archive', $archive_page_id, $post_type );
491
492 if ( $archive_page_id > 0 && WPSEO_Meta::get_value( 'meta-robots-noindex', $archive_page_id ) === '1' ) {
493 return false;
494 }
495
496 return true;
497 }
498
499 /**
500 * Retrieve set of posts with optimized query routine.
501 *
502 * @param string $post_type Post type to retrieve.
503 * @param int $count Count of posts to retrieve.
504 * @param int $offset Starting offset.
505 *
506 * @return object[]
507 */
508 protected function get_posts( $post_type, $count, $offset ) {
509
510 global $wpdb;
511
512 static $filters = [];
513
514 if ( ! isset( $filters[ $post_type ] ) ) {
515 // Make sure you're wpdb->preparing everything you throw into this!!
516 $filters[ $post_type ] = [
517 /**
518 * Filter JOIN query part for the post type.
519 *
520 * @param string $join SQL part, defaults to false.
521 * @param string $post_type Post type name.
522 */
523 'join' => apply_filters( 'wpseo_posts_join', false, $post_type ),
524
525 /**
526 * Filter WHERE query part for the post type.
527 *
528 * @param string $where SQL part, defaults to false.
529 * @param string $post_type Post type name.
530 */
531 'where' => apply_filters( 'wpseo_posts_where', false, $post_type ),
532 ];
533 }
534
535 $join_filter = $filters[ $post_type ]['join'];
536 $where_filter = $filters[ $post_type ]['where'];
537 $where = $this->get_sql_where_clause( $post_type );
538
539 /*
540 * Optimized query per this thread:
541 * {@link http://wordpress.org/support/topic/plugin-wordpress-seo-by-yoast-performance-suggestion}.
542 * Also see {@link http://explainextended.com/2009/10/23/mysql-order-by-limit-performance-late-row-lookups/}.
543 */
544 $sql = "
545 SELECT l.ID, post_title, post_content, post_name, post_parent, post_author, post_status, post_modified_gmt, post_date, post_date_gmt
546 FROM (
547 SELECT {$wpdb->posts}.ID
548 FROM {$wpdb->posts}
549 {$join_filter}
550 {$where}
551 {$where_filter}
552 ORDER BY {$wpdb->posts}.post_modified ASC LIMIT %d OFFSET %d
553 )
554 o JOIN {$wpdb->posts} l ON l.ID = o.ID
555 ";
556
557 $posts = $wpdb->get_results( $wpdb->prepare( $sql, $count, $offset ) );
558
559 $post_ids = [];
560
561 foreach ( $posts as $post_index => $post ) {
562 $post->post_type = $post_type;
563 $sanitized_post = sanitize_post( $post, 'raw' );
564 $posts[ $post_index ] = new WP_Post( $sanitized_post );
565
566 $post_ids[] = $sanitized_post->ID;
567 }
568
569 update_meta_cache( 'post', $post_ids );
570
571 return $posts;
572 }
573
574 /**
575 * Constructs an SQL where clause for a given post type.
576 *
577 * @param string $post_type Post type slug.
578 *
579 * @return string
580 */
581 protected function get_sql_where_clause( $post_type ) {
582
583 global $wpdb;
584
585 $join = '';
586 $post_statuses = array_map( 'esc_sql', WPSEO_Sitemaps::get_post_statuses( $post_type ) );
587 $status_where = "{$wpdb->posts}.post_status IN ('" . implode( "','", $post_statuses ) . "')";
588
589 // Based on WP_Query->get_posts(). R.
590 if ( $post_type === 'attachment' ) {
591 $join = " LEFT JOIN {$wpdb->posts} AS p2 ON ({$wpdb->posts}.post_parent = p2.ID) ";
592 $parent_statuses = array_diff( $post_statuses, [ 'inherit' ] );
593 $status_where = "p2.post_status IN ('" . implode( "','", $parent_statuses ) . "') AND p2.post_password = ''";
594 }
595
596 $where_clause = "
597 {$join}
598 WHERE {$status_where}
599 AND {$wpdb->posts}.post_type = %s
600 AND {$wpdb->posts}.post_password = ''
601 AND {$wpdb->posts}.post_date != '0000-00-00 00:00:00'
602 ";
603
604 return $wpdb->prepare( $where_clause, $post_type );
605 }
606
607 /**
608 * Produce array of URL parts for given post object.
609 *
610 * @param object $post Post object to get URL parts for.
611 *
612 * @return array|bool
613 */
614 protected function get_url( $post ) {
615
616 $url = [];
617
618 /**
619 * Filter the URL Yoast SEO uses in the XML sitemap.
620 *
621 * Note that only absolute local URLs are allowed as the check after this removes external URLs.
622 *
623 * @param string $url URL to use in the XML sitemap
624 * @param object $post Post object for the URL.
625 */
626 $url['loc'] = apply_filters( 'wpseo_xml_sitemap_post_url', get_permalink( $post ), $post );
627 $link_type = YoastSEO()->helpers->url->get_link_type(
628 wp_parse_url( $url['loc'] ),
629 $this->get_parsed_home_url(),
630 );
631
632 /*
633 * Do not include external URLs.
634 *
635 * {@link https://wordpress.org/plugins/page-links-to/} can rewrite permalinks to external URLs.
636 */
637 if ( $link_type === SEO_Links::TYPE_EXTERNAL ) {
638 return false;
639 }
640
641 $modified = max( $post->post_modified_gmt, $post->post_date_gmt );
642
643 if ( $modified !== '0000-00-00 00:00:00' ) {
644 $url['mod'] = $modified;
645 }
646
647 $url['chf'] = 'daily'; // Deprecated, kept for backwards data compat. R.
648
649 $canonical = WPSEO_Meta::get_value( 'canonical', $post->ID );
650
651 if ( $canonical !== '' && $canonical !== $url['loc'] ) {
652 /*
653 * Let's assume that if a canonical is set for this page and it's different from
654 * the URL of this post, that page is either already in the XML sitemap OR is on
655 * an external site, either way, we shouldn't include it here.
656 */
657 return false;
658 }
659 unset( $canonical );
660
661 $url['pri'] = 1; // Deprecated, kept for backwards data compat. R.
662
663 if ( $this->include_images ) {
664 $url['images'] = $this->get_image_parser()->get_images( $post );
665 }
666
667 return $url;
668 }
669
670 /**
671 * Get all dates for a post type by using the WITH clause for performance.
672 *
673 * @param string $post_type Post type to retrieve dates for.
674 * @param int $max_entries Maximum number of entries to retrieve.
675 *
676 * @return array Array of dates.
677 */
678 private function get_all_dates_using_with_clause( $post_type, $max_entries ) {
679 global $wpdb;
680
681 $post_statuses = array_map( 'esc_sql', WPSEO_Sitemaps::get_post_statuses( $post_type ) );
682
683 $replacements = array_merge(
684 [
685 'ordering',
686 'post_modified_gmt',
687 $wpdb->posts,
688 'type_status_date',
689 'post_status',
690 ],
691 $post_statuses,
692 [
693 'post_type',
694 $post_type,
695 'post_modified_gmt',
696 'post_modified_gmt',
697 'ordering',
698 $max_entries,
699 ],
700 );
701
702 //phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- We need to use a direct query here.
703 //phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching -- Reason: No relevant caches.
704 return $wpdb->get_col(
705 //phpcs:disable WordPress.DB.PreparedSQLPlaceholders -- %i placeholder is still not recognized.
706 $wpdb->prepare(
707 '
708 WITH %i AS (SELECT ROW_NUMBER() OVER (ORDER BY %i) AS n, post_modified_gmt
709 FROM %i USE INDEX ( %i )
710 WHERE %i IN (' . implode( ', ', array_fill( 0, count( $post_statuses ), '%s' ) ) . ')
711 AND %i = %s
712 ORDER BY %i)
713 SELECT %i
714 FROM %i
715 WHERE MOD(n, %d) = 0;
716 ',
717 $replacements,
718 ),
719 );
720 }
721
722 /**
723 * Get all dates for a post type.
724 *
725 * @param string $post_type Post type to retrieve dates for.
726 * @param int $max_entries Maximum number of entries to retrieve.
727 *
728 * @return array Array of dates.
729 */
730 private function get_all_dates( $post_type, $max_entries ) {
731 global $wpdb;
732
733 $post_statuses = array_map( 'esc_sql', WPSEO_Sitemaps::get_post_statuses( $post_type ) );
734 $replacements = array_merge(
735 [
736 'post_modified_gmt',
737 $wpdb->posts,
738 'type_status_date',
739 'post_status',
740 ],
741 $post_statuses,
742 [
743 'post_type',
744 $post_type,
745 $max_entries,
746 'post_modified_gmt',
747 ],
748 );
749
750 return $wpdb->get_col(
751 //phpcs:disable WordPress.DB.PreparedSQLPlaceholders -- %i placeholder is still not recognized.
752 $wpdb->prepare(
753 '
754 SELECT %i
755 FROM ( SELECT @rownum:=0 ) init
756 JOIN %i USE INDEX( %i )
757 WHERE %i IN (' . implode( ', ', array_fill( 0, count( $post_statuses ), '%s' ) ) . ')
758 AND %i = %s
759 AND ( @rownum:=@rownum+1 ) %% %d = 0
760 ORDER BY %i ASC
761 ',
762 $replacements,
763 ),
764 );
765 }
766 }
767