PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / 18.6
Yoast SEO – Advanced SEO with real-time guidance and built-in AI v18.6
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 18.6, at inc/sitemaps/class-post-type-sitemap-provider.php

658 lines 16.5 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_filter( '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
98 $post_types = WPSEO_Post_Type::get_accessible_post_types();
99 $post_types = array_filter( $post_types, [ $this, 'is_valid_post_type' ] );
100 $last_modified_times = WPSEO_Sitemaps::get_last_modified_gmt( $post_types, true );
101 $index = [];
102
103 foreach ( $post_types as $post_type ) {
104
105 $total_count = $this->get_post_type_count( $post_type );
106
107 $max_pages = 1;
108 if ( $total_count > $max_entries ) {
109 $max_pages = (int) ceil( $total_count / $max_entries );
110 }
111
112 $all_dates = [];
113
114 if ( $max_pages > 1 ) {
115 $post_statuses = array_map( 'esc_sql', WPSEO_Sitemaps::get_post_statuses( $post_type ) );
116
117 $sql = "
118 SELECT post_modified_gmt
119 FROM ( SELECT @rownum:=0 ) init
120 JOIN {$wpdb->posts} USE INDEX( type_status_date )
121 WHERE post_status IN ('" . implode( "','", $post_statuses ) . "')
122 AND post_type = %s
123 AND ( @rownum:=@rownum+1 ) %% %d = 0
124 ORDER BY post_modified_gmt ASC
125 ";
126
127 $all_dates = $wpdb->get_col( $wpdb->prepare( $sql, $post_type, $max_entries ) );
128 }
129
130 for ( $page_counter = 0; $page_counter < $max_pages; $page_counter++ ) {
131
132 $current_page = ( $page_counter === 0 ) ? '' : ( $page_counter + 1 );
133 $date = false;
134
135 if ( empty( $current_page ) || $current_page === $max_pages ) {
136
137 if ( ! empty( $last_modified_times[ $post_type ] ) ) {
138 $date = $last_modified_times[ $post_type ];
139 }
140 }
141 else {
142 $date = $all_dates[ $page_counter ];
143 }
144
145 $index[] = [
146 'loc' => WPSEO_Sitemaps_Router::get_base_url( $post_type . '-sitemap' . $current_page . '.xml' ),
147 'lastmod' => $date,
148 ];
149 }
150 }
151
152 return $index;
153 }
154
155 /**
156 * Get set of sitemap link data.
157 *
158 * @param string $type Sitemap type.
159 * @param int $max_entries Entries per sitemap.
160 * @param int $current_page Current page of the sitemap.
161 *
162 * @return array
163 *
164 * @throws OutOfBoundsException When an invalid page is requested.
165 */
166 public function get_sitemap_links( $type, $max_entries, $current_page ) {
167
168 $links = [];
169 $post_type = $type;
170
171 if ( ! $this->is_valid_post_type( $post_type ) ) {
172 throw new OutOfBoundsException( 'Invalid sitemap page requested' );
173 }
174
175 $steps = min( 100, $max_entries );
176 $offset = ( $current_page > 1 ) ? ( ( $current_page - 1 ) * $max_entries ) : 0;
177 $total = ( $offset + $max_entries );
178
179 $post_type_entries = $this->get_post_type_count( $post_type );
180
181 if ( $total > $post_type_entries ) {
182 $total = $post_type_entries;
183 }
184
185 if ( $current_page === 1 ) {
186 $links = array_merge( $links, $this->get_first_links( $post_type ) );
187 }
188
189 // If total post type count is lower than the offset, an invalid page is requested.
190 if ( $post_type_entries < $offset ) {
191 throw new OutOfBoundsException( 'Invalid sitemap page requested' );
192 }
193
194 if ( $post_type_entries === 0 ) {
195 return $links;
196 }
197
198 $posts_to_exclude = $this->get_excluded_posts( $type );
199
200 while ( $total > $offset ) {
201
202 $posts = $this->get_posts( $post_type, $steps, $offset );
203
204 $offset += $steps;
205
206 if ( empty( $posts ) ) {
207 continue;
208 }
209
210 foreach ( $posts as $post ) {
211
212 if ( in_array( $post->ID, $posts_to_exclude, true ) ) {
213 continue;
214 }
215
216 if ( WPSEO_Meta::get_value( 'meta-robots-noindex', $post->ID ) === '1' ) {
217 continue;
218 }
219
220 $url = $this->get_url( $post );
221
222 if ( ! isset( $url['loc'] ) ) {
223 continue;
224 }
225
226 /**
227 * Filter URL entry before it gets added to the sitemap.
228 *
229 * @param array $url Array of URL parts.
230 * @param string $type URL type.
231 * @param object $post Data object for the URL.
232 */
233 $url = apply_filters( 'wpseo_sitemap_entry', $url, 'post', $post );
234
235 if ( ! empty( $url ) ) {
236 $links[] = $url;
237 }
238 }
239
240 unset( $post, $url );
241 }
242
243 return $links;
244 }
245
246 /**
247 * Check for relevant post type before invalidation.
248 *
249 * @param int $post_id Post ID to possibly invalidate for.
250 */
251 public function save_post( $post_id ) {
252
253 if ( $this->is_valid_post_type( get_post_type( $post_id ) ) ) {
254 WPSEO_Sitemaps_Cache::invalidate_post( $post_id );
255 }
256 }
257
258 /**
259 * Check if post type should be present in sitemaps.
260 *
261 * @param string $post_type Post type string to check for.
262 *
263 * @return bool
264 */
265 public function is_valid_post_type( $post_type ) {
266 if ( ! WPSEO_Post_Type::is_post_type_accessible( $post_type ) || ! WPSEO_Post_Type::is_post_type_indexable( $post_type ) ) {
267 return false;
268 }
269
270 /**
271 * Filter decision if post type is excluded from the XML sitemap.
272 *
273 * @param bool $exclude Default false.
274 * @param string $post_type Post type name.
275 */
276 if ( apply_filters( 'wpseo_sitemap_exclude_post_type', false, $post_type ) ) {
277 return false;
278 }
279
280 return true;
281 }
282
283 /**
284 * Retrieves a list with the excluded post ids.
285 *
286 * @param string $post_type Post type.
287 *
288 * @return array Array with post ids to exclude.
289 */
290 protected function get_excluded_posts( $post_type ) {
291 $excluded_posts_ids = [];
292
293 $page_on_front_id = ( $post_type === 'page' ) ? (int) get_option( 'page_on_front' ) : 0;
294 if ( $page_on_front_id > 0 ) {
295 $excluded_posts_ids[] = $page_on_front_id;
296 }
297
298 /**
299 * Filter: 'wpseo_exclude_from_sitemap_by_post_ids' - Allow extending and modifying the posts to exclude.
300 *
301 * @api array $posts_to_exclude The posts to exclude.
302 */
303 $excluded_posts_ids = apply_filters( 'wpseo_exclude_from_sitemap_by_post_ids', $excluded_posts_ids );
304 if ( ! is_array( $excluded_posts_ids ) ) {
305 $excluded_posts_ids = [];
306 }
307
308 $excluded_posts_ids = array_map( 'intval', $excluded_posts_ids );
309
310 $page_for_posts_id = ( $post_type === 'page' ) ? (int) get_option( 'page_for_posts' ) : 0;
311 if ( $page_for_posts_id > 0 ) {
312 $excluded_posts_ids[] = $page_for_posts_id;
313 }
314
315 return array_unique( $excluded_posts_ids );
316 }
317
318 /**
319 * Get count of posts for post type.
320 *
321 * @param string $post_type Post type to retrieve count for.
322 *
323 * @return int
324 */
325 protected function get_post_type_count( $post_type ) {
326
327 global $wpdb;
328
329 /**
330 * Filter JOIN query part for type count of post type.
331 *
332 * @param string $join SQL part, defaults to empty string.
333 * @param string $post_type Post type name.
334 */
335 $join_filter = apply_filters( 'wpseo_typecount_join', '', $post_type );
336
337 /**
338 * Filter WHERE query part for type count of post type.
339 *
340 * @param string $where SQL part, defaults to empty string.
341 * @param string $post_type Post type name.
342 */
343 $where_filter = apply_filters( 'wpseo_typecount_where', '', $post_type );
344
345 $where = $this->get_sql_where_clause( $post_type );
346
347 $sql = "
348 SELECT COUNT({$wpdb->posts}.ID)
349 FROM {$wpdb->posts}
350 {$join_filter}
351 {$where}
352 {$where_filter}
353 ";
354
355 return (int) $wpdb->get_var( $sql );
356 }
357
358 /**
359 * Produces set of links to prepend at start of first sitemap page.
360 *
361 * @param string $post_type Post type to produce links for.
362 *
363 * @return array
364 */
365 protected function get_first_links( $post_type ) {
366
367 $links = [];
368 $archive_url = false;
369
370 if ( $post_type === 'page' ) {
371
372 $page_on_front_id = (int) get_option( 'page_on_front' );
373 if ( $page_on_front_id > 0 ) {
374 $front_page = $this->get_url(
375 get_post( $page_on_front_id )
376 );
377 }
378
379 if ( empty( $front_page ) ) {
380 $front_page = [
381 'loc' => YoastSEO()->helpers->url->home(),
382 ];
383 }
384
385 // Deprecated, kept for backwards data compat. R.
386 $front_page['chf'] = 'daily';
387 $front_page['pri'] = 1;
388
389 $links[] = $front_page;
390 }
391 elseif ( $post_type !== 'page' ) {
392 /**
393 * Filter the URL Yoast SEO uses in the XML sitemap for this post type archive.
394 *
395 * @param string $archive_url The URL of this archive
396 * @param string $post_type The post type this archive is for.
397 */
398 $archive_url = apply_filters(
399 'wpseo_sitemap_post_type_archive_link',
400 $this->get_post_type_archive_link( $post_type ),
401 $post_type
402 );
403 }
404
405 if ( $archive_url ) {
406
407 $links[] = [
408 'loc' => $archive_url,
409 'mod' => WPSEO_Sitemaps::get_last_modified_gmt( $post_type ),
410
411 // Deprecated, kept for backwards data compat. R.
412 'chf' => 'daily',
413 'pri' => 1,
414 ];
415 }
416
417 return $links;
418 }
419
420 /**
421 * Get URL for a post type archive.
422 *
423 * @since 5.3
424 *
425 * @param string $post_type Post type.
426 *
427 * @return string|bool URL or false if it should be excluded.
428 */
429 protected function get_post_type_archive_link( $post_type ) {
430
431 $pt_archive_page_id = -1;
432
433 if ( $post_type === 'post' ) {
434
435 if ( get_option( 'show_on_front' ) === 'posts' ) {
436 return YoastSEO()->helpers->url->home();
437 }
438
439 $pt_archive_page_id = (int) get_option( 'page_for_posts' );
440
441 // Post archive should be excluded if posts page isn't set.
442 if ( $pt_archive_page_id <= 0 ) {
443 return false;
444 }
445 }
446
447 if ( ! $this->is_post_type_archive_indexable( $post_type, $pt_archive_page_id ) ) {
448 return false;
449 }
450
451 return get_post_type_archive_link( $post_type );
452 }
453
454 /**
455 * Determines whether a post type archive is indexable.
456 *
457 * @since 11.5
458 *
459 * @param string $post_type Post type.
460 * @param int $archive_page_id The page id.
461 *
462 * @return bool True when post type archive is indexable.
463 */
464 protected function is_post_type_archive_indexable( $post_type, $archive_page_id = -1 ) {
465
466 if ( WPSEO_Options::get( 'noindex-ptarchive-' . $post_type, false ) ) {
467 return false;
468 }
469
470 /**
471 * Filter the page which is dedicated to this post type archive.
472 *
473 * @since 9.3
474 *
475 * @param string $archive_page_id The post_id of the page.
476 * @param string $post_type The post type this archive is for.
477 */
478 $archive_page_id = (int) apply_filters( 'wpseo_sitemap_page_for_post_type_archive', $archive_page_id, $post_type );
479
480 if ( $archive_page_id > 0 && WPSEO_Meta::get_value( 'meta-robots-noindex', $archive_page_id ) === '1' ) {
481 return false;
482 }
483
484 return true;
485 }
486
487 /**
488 * Retrieve set of posts with optimized query routine.
489 *
490 * @param string $post_type Post type to retrieve.
491 * @param int $count Count of posts to retrieve.
492 * @param int $offset Starting offset.
493 *
494 * @return object[]
495 */
496 protected function get_posts( $post_type, $count, $offset ) {
497
498 global $wpdb;
499
500 static $filters = [];
501
502 if ( ! isset( $filters[ $post_type ] ) ) {
503 // Make sure you're wpdb->preparing everything you throw into this!!
504 $filters[ $post_type ] = [
505 /**
506 * Filter JOIN query part for the post type.
507 *
508 * @param string $join SQL part, defaults to false.
509 * @param string $post_type Post type name.
510 */
511 'join' => apply_filters( 'wpseo_posts_join', false, $post_type ),
512
513 /**
514 * Filter WHERE query part for the post type.
515 *
516 * @param string $where SQL part, defaults to false.
517 * @param string $post_type Post type name.
518 */
519 'where' => apply_filters( 'wpseo_posts_where', false, $post_type ),
520 ];
521 }
522
523 $join_filter = $filters[ $post_type ]['join'];
524 $where_filter = $filters[ $post_type ]['where'];
525 $where = $this->get_sql_where_clause( $post_type );
526
527 /*
528 * Optimized query per this thread:
529 * {@link http://wordpress.org/support/topic/plugin-wordpress-seo-by-yoast-performance-suggestion}.
530 * Also see {@link http://explainextended.com/2009/10/23/mysql-order-by-limit-performance-late-row-lookups/}.
531 */
532 $sql = "
533 SELECT l.ID, post_title, post_content, post_name, post_parent, post_author, post_status, post_modified_gmt, post_date, post_date_gmt
534 FROM (
535 SELECT {$wpdb->posts}.ID
536 FROM {$wpdb->posts}
537 {$join_filter}
538 {$where}
539 {$where_filter}
540 ORDER BY {$wpdb->posts}.post_modified ASC LIMIT %d OFFSET %d
541 )
542 o JOIN {$wpdb->posts} l ON l.ID = o.ID
543 ";
544
545 $posts = $wpdb->get_results( $wpdb->prepare( $sql, $count, $offset ) );
546
547 $post_ids = [];
548
549 foreach ( $posts as $post_index => $post ) {
550 $post->post_type = $post_type;
551 $sanitized_post = sanitize_post( $post, 'raw' );
552 $posts[ $post_index ] = new WP_Post( $sanitized_post );
553
554 $post_ids[] = $sanitized_post->ID;
555 }
556
557 update_meta_cache( 'post', $post_ids );
558
559 return $posts;
560 }
561
562 /**
563 * Constructs an SQL where clause for a given post type.
564 *
565 * @param string $post_type Post type slug.
566 *
567 * @return string
568 */
569 protected function get_sql_where_clause( $post_type ) {
570
571 global $wpdb;
572
573 $join = '';
574 $post_statuses = array_map( 'esc_sql', WPSEO_Sitemaps::get_post_statuses( $post_type ) );
575 $status_where = "{$wpdb->posts}.post_status IN ('" . implode( "','", $post_statuses ) . "')";
576
577 // Based on WP_Query->get_posts(). R.
578 if ( $post_type === 'attachment' ) {
579 $join = " LEFT JOIN {$wpdb->posts} AS p2 ON ({$wpdb->posts}.post_parent = p2.ID) ";
580 $parent_statuses = array_diff( $post_statuses, [ 'inherit' ] );
581 $status_where = "p2.post_status IN ('" . implode( "','", $parent_statuses ) . "') AND p2.post_password = ''";
582 }
583
584 $where_clause = "
585 {$join}
586 WHERE {$status_where}
587 AND {$wpdb->posts}.post_type = %s
588 AND {$wpdb->posts}.post_password = ''
589 AND {$wpdb->posts}.post_date != '0000-00-00 00:00:00'
590 ";
591
592 return $wpdb->prepare( $where_clause, $post_type );
593 }
594
595 /**
596 * Produce array of URL parts for given post object.
597 *
598 * @param object $post Post object to get URL parts for.
599 *
600 * @return array|bool
601 */
602 protected function get_url( $post ) {
603
604 $url = [];
605
606 /**
607 * Filter the URL Yoast SEO uses in the XML sitemap.
608 *
609 * Note that only absolute local URLs are allowed as the check after this removes external URLs.
610 *
611 * @param string $url URL to use in the XML sitemap
612 * @param object $post Post object for the URL.
613 */
614 $url['loc'] = apply_filters( 'wpseo_xml_sitemap_post_url', get_permalink( $post ), $post );
615 $link_type = YoastSEO()->helpers->url->get_link_type(
616 wp_parse_url( $url['loc'] ),
617 $this->get_parsed_home_url()
618 );
619
620 /*
621 * Do not include external URLs.
622 *
623 * {@link https://wordpress.org/plugins/page-links-to/} can rewrite permalinks to external URLs.
624 */
625 if ( $link_type === SEO_Links::TYPE_EXTERNAL ) {
626 return false;
627 }
628
629 $modified = max( $post->post_modified_gmt, $post->post_date_gmt );
630
631 if ( $modified !== '0000-00-00 00:00:00' ) {
632 $url['mod'] = $modified;
633 }
634
635 $url['chf'] = 'daily'; // Deprecated, kept for backwards data compat. R.
636
637 $canonical = WPSEO_Meta::get_value( 'canonical', $post->ID );
638
639 if ( $canonical !== '' && $canonical !== $url['loc'] ) {
640 /*
641 * Let's assume that if a canonical is set for this page and it's different from
642 * the URL of this post, that page is either already in the XML sitemap OR is on
643 * an external site, either way, we shouldn't include it here.
644 */
645 return false;
646 }
647 unset( $canonical );
648
649 $url['pri'] = 1; // Deprecated, kept for backwards data compat. R.
650
651 if ( $this->include_images ) {
652 $url['images'] = $this->get_image_parser()->get_images( $post );
653 }
654
655 return $url;
656 }
657 }
658