PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / 28.3
Yoast SEO – Advanced SEO with real-time guidance and built-in AI v28.3
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 / src / builders / indexable-link-builder.php

indexable-link-builder.php in Yoast SEO – Advanced SEO with real-time guidance and built-in AI 28.3, at src/builders/indexable-link-builder.php

616 lines 18.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Yoast\WP\SEO\Builders;
4
5 use WPSEO_Image_Utils;
6 use Yoast\WP\SEO\Helpers\Image_Helper;
7 use Yoast\WP\SEO\Helpers\Indexable_Helper;
8 use Yoast\WP\SEO\Helpers\Options_Helper;
9 use Yoast\WP\SEO\Helpers\Post_Helper;
10 use Yoast\WP\SEO\Helpers\Url_Helper;
11 use Yoast\WP\SEO\Images\Application\Image_Content_Extractor;
12 use Yoast\WP\SEO\Models\Indexable;
13 use Yoast\WP\SEO\Models\SEO_Links;
14 use Yoast\WP\SEO\Repositories\Indexable_Repository;
15 use Yoast\WP\SEO\Repositories\SEO_Links_Repository;
16
17 /**
18 * Indexable link builder.
19 */
20 class Indexable_Link_Builder {
21
22 /**
23 * The SEO links repository.
24 *
25 * @var SEO_Links_Repository
26 */
27 protected $seo_links_repository;
28
29 /**
30 * The url helper.
31 *
32 * @var Url_Helper
33 */
34 protected $url_helper;
35
36 /**
37 * The image helper.
38 *
39 * @var Image_Helper
40 */
41 protected $image_helper;
42
43 /**
44 * The indexable helper.
45 *
46 * @var Indexable_Helper
47 */
48 protected $indexable_helper;
49
50 /**
51 * The post helper.
52 *
53 * @var Post_Helper
54 */
55 protected $post_helper;
56
57 /**
58 * The options helper.
59 *
60 * @var Options_Helper
61 */
62 protected $options_helper;
63
64 /**
65 * The indexable repository.
66 *
67 * @var Indexable_Repository
68 */
69 protected $indexable_repository;
70
71 /**
72 * Class that finds all images in a content string and extracts them.
73 *
74 * @var Image_Content_Extractor
75 */
76 private $image_content_extractor;
77
78 /**
79 * Indexable_Link_Builder constructor.
80 *
81 * @param SEO_Links_Repository $seo_links_repository The SEO links repository.
82 * @param Url_Helper $url_helper The URL helper.
83 * @param Post_Helper $post_helper The post helper.
84 * @param Options_Helper $options_helper The options helper.
85 * @param Indexable_Helper $indexable_helper The indexable helper.
86 */
87 public function __construct(
88 SEO_Links_Repository $seo_links_repository,
89 Url_Helper $url_helper,
90 Post_Helper $post_helper,
91 Options_Helper $options_helper,
92 Indexable_Helper $indexable_helper,
93 Image_Content_Extractor $image_content_extractor
94 ) {
95 $this->seo_links_repository = $seo_links_repository;
96 $this->url_helper = $url_helper;
97 $this->post_helper = $post_helper;
98 $this->options_helper = $options_helper;
99 $this->indexable_helper = $indexable_helper;
100 $this->image_content_extractor = $image_content_extractor;
101 }
102
103 /**
104 * Sets the indexable repository.
105 *
106 * @required
107 *
108 * @param Indexable_Repository $indexable_repository The indexable repository.
109 * @param Image_Helper $image_helper The image helper.
110 *
111 * @return void
112 */
113 public function set_dependencies( Indexable_Repository $indexable_repository, Image_Helper $image_helper ) {
114 $this->indexable_repository = $indexable_repository;
115 $this->image_helper = $image_helper;
116 }
117
118 /**
119 * Builds the links for a post.
120 *
121 * @param Indexable $indexable The indexable.
122 * @param string $content The content. Expected to be unfiltered.
123 *
124 * @return SEO_Links[] The created SEO links.
125 */
126 public function build( $indexable, $content ) {
127 if ( ! $this->indexable_helper->should_index_indexable( $indexable ) ) {
128 return [];
129 }
130
131 global $post;
132 if ( $indexable->object_type === 'post' ) {
133 $post_backup = $post;
134 // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- To setup the post we need to do this explicitly.
135 $post = $this->post_helper->get_post( $indexable->object_id );
136 \setup_postdata( $post );
137
138 // The below hook primes the post and meta caches for all wp-image-<ID> images.
139 // So the image loop in create_links() hits warm caches — no additional priming is needed there.
140 $content = \apply_filters( 'the_content', $content );
141 \wp_reset_postdata();
142 // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- To setup the post we need to do this explicitly.
143 $post = $post_backup;
144 }
145
146 $content = \str_replace( ']]>', ']]&gt;', $content );
147 $links = $this->gather_links( $content );
148 $images = $this->image_content_extractor->gather_images( $content );
149
150 if ( empty( $links ) && empty( $images ) ) {
151 $indexable->link_count = 0;
152 $this->update_related_indexables( $indexable, [] );
153
154 return [];
155 }
156
157 if ( ! empty( $images ) && ( $indexable->open_graph_image_source === 'first-content-image' || $indexable->twitter_image_source === 'first-content-image' ) ) {
158 $this->update_first_content_image( $indexable, $images );
159 }
160
161 $links = $this->create_links( $indexable, $links, $images );
162
163 $this->update_related_indexables( $indexable, $links );
164
165 $indexable->link_count = $this->get_internal_link_count( $links );
166
167 return $links;
168 }
169
170 /**
171 * Deletes all SEO links for an indexable.
172 *
173 * @param Indexable $indexable The indexable.
174 *
175 * @return void
176 */
177 public function delete( $indexable ) {
178 $links = ( $this->seo_links_repository->find_all_by_indexable_id( $indexable->id ) );
179 $this->seo_links_repository->delete_all_by_indexable_id( $indexable->id );
180
181 $linked_indexable_ids = [];
182 foreach ( $links as $link ) {
183 if ( $link->target_indexable_id ) {
184 $linked_indexable_ids[] = $link->target_indexable_id;
185 }
186 }
187
188 $this->update_incoming_links_for_related_indexables( $linked_indexable_ids );
189 }
190
191 /**
192 * Fixes existing SEO links that are supposed to have a target indexable but don't, because of prior indexable
193 * cleanup.
194 *
195 * @param Indexable $indexable The indexable to be the target of SEO Links.
196 *
197 * @return void
198 */
199 public function patch_seo_links( Indexable $indexable ) {
200 if ( ! empty( $indexable->id ) && ! empty( $indexable->object_id ) ) {
201 $links = $this->seo_links_repository->find_all_by_target_post_id( $indexable->object_id );
202
203 $updated_indexable = false;
204 foreach ( $links as $link ) {
205 if ( \is_a( $link, SEO_Links::class ) && empty( $link->target_indexable_id ) ) {
206 // Since that post ID exists in an SEO link but has no target_indexable_id, it's probably because of prior indexable cleanup.
207 $this->seo_links_repository->update_target_indexable_id( $link->id, $indexable->id );
208 $updated_indexable = true;
209 }
210 }
211
212 if ( $updated_indexable ) {
213 $updated_indexable_id = [ $indexable->id ];
214 $this->update_incoming_links_for_related_indexables( $updated_indexable_id );
215 }
216 }
217 }
218
219 /**
220 * Gathers all links from content.
221 *
222 * @param string $content The content.
223 *
224 * @return string[] An array of urls.
225 */
226 protected function gather_links( $content ) {
227 if ( \strpos( $content, 'href' ) === false ) {
228 // Nothing to do.
229 return [];
230 }
231
232 $links = [];
233 $regexp = '<a\s[^>]*href=("??)([^" >]*?)\1[^>]*>';
234 // Used modifiers iU to match case insensitive and make greedy quantifiers lazy.
235 if ( \preg_match_all( "/$regexp/iU", $content, $matches, \PREG_SET_ORDER ) ) {
236 foreach ( $matches as $match ) {
237 $links[] = \trim( $match[2], "'" );
238 }
239 }
240
241 return $links;
242 }
243
244 /**
245 * Creates link models from lists of URLs and image sources.
246 *
247 * @param Indexable $indexable The indexable.
248 * @param string[] $links The link URLs.
249 * @param int[] $images The image sources.
250 *
251 * @return SEO_Links[] The link models.
252 */
253 protected function create_links( $indexable, $links, $images ) {
254 $home_url = \wp_parse_url( \home_url() );
255 $current_url = \wp_parse_url( $indexable->permalink );
256 $links = \array_map(
257 function ( $link ) use ( $home_url, $indexable ) {
258 return $this->create_internal_link( $link, $home_url, $indexable );
259 },
260 $links,
261 );
262 // Filter out links to the same page with a fragment or query.
263 $links = \array_filter(
264 $links,
265 function ( $link ) use ( $current_url ) {
266 return $this->filter_link( $link, $current_url );
267 },
268 );
269
270 $image_links = [];
271 foreach ( $images as $image_url => $image_id ) {
272 $image_links[] = $this->create_internal_link( $image_url, $home_url, $indexable, true, $image_id );
273 }
274
275 return \array_merge( $links, $image_links );
276 }
277
278 /**
279 * Get the post ID based on the link's type and its target's permalink.
280 *
281 * @param string $type The type of link (either SEO_Links::TYPE_INTERNAL or SEO_Links::TYPE_INTERNAL_IMAGE).
282 * @param string $permalink The permalink of the link's target.
283 *
284 * @return int The post ID.
285 */
286 protected function get_post_id( $type, $permalink ) {
287 if ( $type === SEO_Links::TYPE_INTERNAL ) {
288 return \url_to_postid( $permalink );
289 }
290
291 return $this->image_helper->get_attachment_by_url( $permalink );
292 }
293
294 /**
295 * Creates an internal link.
296 *
297 * @param string $url The url of the link.
298 * @param array $home_url The home url, as parsed by wp_parse_url.
299 * @param Indexable $indexable The indexable of the post containing the link.
300 * @param bool $is_image Whether or not the link is an image.
301 * @param int $image_id The ID of the internal image.
302 *
303 * @return SEO_Links The created link.
304 */
305 protected function create_internal_link( $url, $home_url, $indexable, $is_image = false, $image_id = 0 ) {
306 $parsed_url = \wp_parse_url( $url );
307 $link_type = $this->url_helper->get_link_type( $parsed_url, $home_url, $is_image );
308
309 /**
310 * ORM representing a link in the SEO Links table.
311 *
312 * @var SEO_Links $model
313 */
314 $model = $this->seo_links_repository->query()->create(
315 [
316 'url' => $url,
317 'type' => $link_type,
318 'indexable_id' => $indexable->id,
319 'post_id' => $indexable->object_id,
320 ],
321 );
322
323 $model->parsed_url = $parsed_url;
324
325 if ( $model->type === SEO_Links::TYPE_INTERNAL ) {
326 $permalink = $this->build_permalink( $url, $home_url );
327
328 return $this->enhance_link_from_indexable( $model, $permalink );
329 }
330
331 if ( $model->type === SEO_Links::TYPE_INTERNAL_IMAGE ) {
332 $permalink = $this->build_permalink( $url, $home_url );
333
334 /** The `wpseo_force_creating_and_using_attachment_indexables` filter is documented in indexable-link-builder.php */
335 if ( ! $this->options_helper->get( 'disable-attachment' ) || \apply_filters( 'wpseo_force_creating_and_using_attachment_indexables', false ) ) {
336 $model = $this->enhance_link_from_indexable( $model, $permalink );
337 }
338 else {
339 $target_post_id = ( $image_id !== 0 ) ? $image_id : WPSEO_Image_Utils::get_attachment_by_url( $permalink );
340
341 if ( ! empty( $target_post_id ) ) {
342 $model->target_post_id = $target_post_id;
343 }
344 }
345
346 if ( $model->target_post_id ) {
347 $file = \get_attached_file( $model->target_post_id );
348
349 if ( $file ) {
350 if ( \file_exists( $file ) ) {
351 $model->size = \filesize( $file );
352 }
353 else {
354 $model->size = null;
355 }
356
357 [ , $width, $height ] = \wp_get_attachment_image_src( $model->target_post_id, 'full' );
358 $model->width = $width;
359 $model->height = $height;
360 }
361 else {
362 $model->width = 0;
363 $model->height = 0;
364 $model->size = 0;
365 }
366 }
367 }
368
369 return $model;
370 }
371
372 /**
373 * Enhances the link model with information from its indexable.
374 *
375 * @param SEO_Links $model The link's model.
376 * @param string $permalink The link's permalink.
377 *
378 * @return SEO_Links The enhanced link model.
379 */
380 protected function enhance_link_from_indexable( $model, $permalink ) {
381 $target = $this->indexable_repository->find_by_permalink( $permalink );
382
383 if ( ! $target ) {
384 // If target indexable cannot be found, create one based on the post's post ID.
385 $post_id = $this->get_post_id( $model->type, $permalink );
386 if ( $post_id && $post_id !== 0 ) {
387 $target = $this->indexable_repository->find_by_id_and_type( $post_id, 'post' );
388 }
389 }
390
391 if ( ! $target ) {
392 return $model;
393 }
394
395 $model->target_indexable_id = $target->id;
396 if ( $target->object_type === 'post' ) {
397 $model->target_post_id = $target->object_id;
398 }
399
400 if ( $model->target_indexable_id ) {
401 $model->language = $target->language;
402 $model->region = $target->region;
403 }
404
405 return $model;
406 }
407
408 /**
409 * Builds the link's permalink.
410 *
411 * @param string $url The url of the link.
412 * @param array $home_url The home url, as parsed by wp_parse_url.
413 *
414 * @return string The link's permalink.
415 */
416 protected function build_permalink( $url, $home_url ) {
417 $permalink = $this->get_permalink( $url, $home_url );
418
419 if ( $this->url_helper->is_relative( $permalink ) ) {
420 // Make sure we're checking against the absolute URL, and add a trailing slash if the site has a trailing slash in its permalink settings.
421 $permalink = $this->url_helper->ensure_absolute_url( \user_trailingslashit( $permalink ) );
422 }
423
424 return $permalink;
425 }
426
427 /**
428 * Filters out links that point to the same page with a fragment or query.
429 *
430 * @param SEO_Links $link The link.
431 * @param array $current_url The url of the page the link is on, as parsed by wp_parse_url.
432 *
433 * @return bool Whether or not the link should be filtered.
434 */
435 protected function filter_link( SEO_Links $link, $current_url ) {
436 $url = $link->parsed_url;
437
438 // Always keep external links.
439 if ( $link->type === SEO_Links::TYPE_EXTERNAL ) {
440 return true;
441 }
442
443 // Always keep links with an empty path or pointing to other pages.
444 if ( isset( $url['path'] ) ) {
445 return empty( $url['path'] ) || $url['path'] !== $current_url['path'];
446 }
447
448 // Only keep links to the current page without a fragment or query.
449 return ( ! isset( $url['fragment'] ) && ! isset( $url['query'] ) );
450 }
451
452 /**
453 * Updates the link counts for related indexables.
454 *
455 * @param Indexable $indexable The indexable.
456 * @param SEO_Links[] $links The link models.
457 *
458 * @return void
459 */
460 protected function update_related_indexables( $indexable, $links ) {
461 // Old links were only stored by post id, so remove all old seo links for this post that have no indexable id.
462 // This can be removed if we ever fully clear all seo links.
463 if ( $indexable->object_type === 'post' ) {
464 $this->seo_links_repository->delete_all_by_post_id_where_indexable_id_null( $indexable->object_id );
465 }
466
467 $updated_indexable_ids = [];
468 $old_links = $this->seo_links_repository->find_all_by_indexable_id( $indexable->id );
469
470 $links_to_remove = $this->links_diff( $old_links, $links );
471 $links_to_add = $this->links_diff( $links, $old_links );
472
473 if ( ! empty( $links_to_remove ) ) {
474 $this->seo_links_repository->delete_many_by_id( \wp_list_pluck( $links_to_remove, 'id' ) );
475 }
476
477 if ( ! empty( $links_to_add ) ) {
478 $this->seo_links_repository->insert_many( $links_to_add );
479 }
480
481 foreach ( $links_to_add as $link ) {
482 if ( $link->target_indexable_id ) {
483 $updated_indexable_ids[] = $link->target_indexable_id;
484 }
485 }
486 foreach ( $links_to_remove as $link ) {
487 if ( $link->target_indexable_id ) {
488 $updated_indexable_ids[] = $link->target_indexable_id;
489 }
490 }
491
492 $this->update_incoming_links_for_related_indexables( $updated_indexable_ids );
493 }
494
495 /**
496 * Creates a diff between two arrays of SEO links, based on urls.
497 *
498 * @param SEO_Links[] $links_a The array to compare.
499 * @param SEO_Links[] $links_b The array to compare against.
500 *
501 * @return SEO_Links[] Links that are in $links_a, but not in $links_b.
502 */
503 protected function links_diff( $links_a, $links_b ) {
504 return \array_udiff(
505 $links_a,
506 $links_b,
507 static function ( SEO_Links $link_a, SEO_Links $link_b ) {
508 return \strcmp( $link_a->url, $link_b->url );
509 },
510 );
511 }
512
513 /**
514 * Returns the number of internal links in an array of link models.
515 *
516 * @param SEO_Links[] $links The link models.
517 *
518 * @return int The number of internal links.
519 */
520 protected function get_internal_link_count( $links ) {
521 $internal_link_count = 0;
522
523 foreach ( $links as $link ) {
524 if ( $link->type === SEO_Links::TYPE_INTERNAL ) {
525 ++$internal_link_count;
526 }
527 }
528
529 return $internal_link_count;
530 }
531
532 /**
533 * Returns a cleaned permalink for a given link.
534 *
535 * @param string $link The raw URL.
536 * @param array $home_url The home URL, as parsed by wp_parse_url.
537 *
538 * @return string The cleaned permalink.
539 */
540 protected function get_permalink( $link, $home_url ) {
541 // Get rid of the #anchor.
542 $url_split = \explode( '#', $link );
543 $link = $url_split[0];
544
545 // Get rid of URL ?query=string.
546 $url_split = \explode( '?', $link );
547 $link = $url_split[0];
548
549 // Set the correct URL scheme.
550 $link = \set_url_scheme( $link, $home_url['scheme'] );
551
552 // Add 'www.' if it is absent and should be there.
553 if ( \strpos( $home_url['host'], 'www.' ) === 0 && \strpos( $link, '://www.' ) === false ) {
554 $link = \str_replace( '://', '://www.', $link );
555 }
556
557 // Strip 'www.' if it is present and shouldn't be.
558 if ( \strpos( $home_url['host'], 'www.' ) !== 0 ) {
559 $link = \str_replace( '://www.', '://', $link );
560 }
561
562 return $link;
563 }
564
565 /**
566 * Updates incoming link counts for related indexables.
567 *
568 * @param int[] $related_indexable_ids The IDs of all related indexables.
569 *
570 * @return void
571 */
572 protected function update_incoming_links_for_related_indexables( $related_indexable_ids ) {
573 if ( empty( $related_indexable_ids ) ) {
574 return;
575 }
576
577 $counts = $this->seo_links_repository->get_incoming_link_counts_for_indexable_ids( $related_indexable_ids );
578
579 /**
580 * Fires to signal that incoming link counts for related indexables were updated.
581 *
582 * @param int[] $related_indexable_ids The related indexable Ids to this link change.
583 *
584 * @internal
585 */
586 \do_action( 'wpseo_related_indexables_incoming_links_updated', $related_indexable_ids );
587
588 foreach ( $counts as $count ) {
589 $this->indexable_repository->update_incoming_link_count( $count['target_indexable_id'], $count['incoming'] );
590 }
591 }
592
593 /**
594 * Updates the image ids when the indexable images are marked as first content image.
595 *
596 * @param Indexable $indexable The indexable to change.
597 * @param array<string|int> $images The image array.
598 *
599 * @return void
600 */
601 public function update_first_content_image( Indexable $indexable, array $images ): void {
602 $current_open_graph_image = $indexable->open_graph_image;
603 $current_twitter_image = $indexable->twitter_image;
604
605 $first_content_image_url = \key( $images );
606 $first_content_image_id = \current( $images );
607
608 if ( $indexable->open_graph_image_source === 'first-content-image' && $current_open_graph_image === $first_content_image_url && ! empty( $first_content_image_id ) ) {
609 $indexable->open_graph_image_id = $first_content_image_id;
610 }
611 if ( $indexable->twitter_image_source === 'first-content-image' && $current_twitter_image === $first_content_image_url && ! empty( $first_content_image_id ) ) {
612 $indexable->twitter_image_id = $first_content_image_id;
613 }
614 }
615 }
616