PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / 27.8
Yoast SEO – Advanced SEO with real-time guidance and built-in AI v27.8
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 27.8, at src/builders/indexable-link-builder.php

613 lines 18.0 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 $content = \apply_filters( 'the_content', $content );
138 \wp_reset_postdata();
139 // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- To setup the post we need to do this explicitly.
140 $post = $post_backup;
141 }
142
143 $content = \str_replace( ']]>', ']]&gt;', $content );
144 $links = $this->gather_links( $content );
145 $images = $this->image_content_extractor->gather_images( $content );
146
147 if ( empty( $links ) && empty( $images ) ) {
148 $indexable->link_count = 0;
149 $this->update_related_indexables( $indexable, [] );
150
151 return [];
152 }
153
154 if ( ! empty( $images ) && ( $indexable->open_graph_image_source === 'first-content-image' || $indexable->twitter_image_source === 'first-content-image' ) ) {
155 $this->update_first_content_image( $indexable, $images );
156 }
157
158 $links = $this->create_links( $indexable, $links, $images );
159
160 $this->update_related_indexables( $indexable, $links );
161
162 $indexable->link_count = $this->get_internal_link_count( $links );
163
164 return $links;
165 }
166
167 /**
168 * Deletes all SEO links for an indexable.
169 *
170 * @param Indexable $indexable The indexable.
171 *
172 * @return void
173 */
174 public function delete( $indexable ) {
175 $links = ( $this->seo_links_repository->find_all_by_indexable_id( $indexable->id ) );
176 $this->seo_links_repository->delete_all_by_indexable_id( $indexable->id );
177
178 $linked_indexable_ids = [];
179 foreach ( $links as $link ) {
180 if ( $link->target_indexable_id ) {
181 $linked_indexable_ids[] = $link->target_indexable_id;
182 }
183 }
184
185 $this->update_incoming_links_for_related_indexables( $linked_indexable_ids );
186 }
187
188 /**
189 * Fixes existing SEO links that are supposed to have a target indexable but don't, because of prior indexable
190 * cleanup.
191 *
192 * @param Indexable $indexable The indexable to be the target of SEO Links.
193 *
194 * @return void
195 */
196 public function patch_seo_links( Indexable $indexable ) {
197 if ( ! empty( $indexable->id ) && ! empty( $indexable->object_id ) ) {
198 $links = $this->seo_links_repository->find_all_by_target_post_id( $indexable->object_id );
199
200 $updated_indexable = false;
201 foreach ( $links as $link ) {
202 if ( \is_a( $link, SEO_Links::class ) && empty( $link->target_indexable_id ) ) {
203 // Since that post ID exists in an SEO link but has no target_indexable_id, it's probably because of prior indexable cleanup.
204 $this->seo_links_repository->update_target_indexable_id( $link->id, $indexable->id );
205 $updated_indexable = true;
206 }
207 }
208
209 if ( $updated_indexable ) {
210 $updated_indexable_id = [ $indexable->id ];
211 $this->update_incoming_links_for_related_indexables( $updated_indexable_id );
212 }
213 }
214 }
215
216 /**
217 * Gathers all links from content.
218 *
219 * @param string $content The content.
220 *
221 * @return string[] An array of urls.
222 */
223 protected function gather_links( $content ) {
224 if ( \strpos( $content, 'href' ) === false ) {
225 // Nothing to do.
226 return [];
227 }
228
229 $links = [];
230 $regexp = '<a\s[^>]*href=("??)([^" >]*?)\1[^>]*>';
231 // Used modifiers iU to match case insensitive and make greedy quantifiers lazy.
232 if ( \preg_match_all( "/$regexp/iU", $content, $matches, \PREG_SET_ORDER ) ) {
233 foreach ( $matches as $match ) {
234 $links[] = \trim( $match[2], "'" );
235 }
236 }
237
238 return $links;
239 }
240
241 /**
242 * Creates link models from lists of URLs and image sources.
243 *
244 * @param Indexable $indexable The indexable.
245 * @param string[] $links The link URLs.
246 * @param int[] $images The image sources.
247 *
248 * @return SEO_Links[] The link models.
249 */
250 protected function create_links( $indexable, $links, $images ) {
251 $home_url = \wp_parse_url( \home_url() );
252 $current_url = \wp_parse_url( $indexable->permalink );
253 $links = \array_map(
254 function ( $link ) use ( $home_url, $indexable ) {
255 return $this->create_internal_link( $link, $home_url, $indexable );
256 },
257 $links,
258 );
259 // Filter out links to the same page with a fragment or query.
260 $links = \array_filter(
261 $links,
262 function ( $link ) use ( $current_url ) {
263 return $this->filter_link( $link, $current_url );
264 },
265 );
266
267 $image_links = [];
268 foreach ( $images as $image_url => $image_id ) {
269 $image_links[] = $this->create_internal_link( $image_url, $home_url, $indexable, true, $image_id );
270 }
271
272 return \array_merge( $links, $image_links );
273 }
274
275 /**
276 * Get the post ID based on the link's type and its target's permalink.
277 *
278 * @param string $type The type of link (either SEO_Links::TYPE_INTERNAL or SEO_Links::TYPE_INTERNAL_IMAGE).
279 * @param string $permalink The permalink of the link's target.
280 *
281 * @return int The post ID.
282 */
283 protected function get_post_id( $type, $permalink ) {
284 if ( $type === SEO_Links::TYPE_INTERNAL ) {
285 return \url_to_postid( $permalink );
286 }
287
288 return $this->image_helper->get_attachment_by_url( $permalink );
289 }
290
291 /**
292 * Creates an internal link.
293 *
294 * @param string $url The url of the link.
295 * @param array $home_url The home url, as parsed by wp_parse_url.
296 * @param Indexable $indexable The indexable of the post containing the link.
297 * @param bool $is_image Whether or not the link is an image.
298 * @param int $image_id The ID of the internal image.
299 *
300 * @return SEO_Links The created link.
301 */
302 protected function create_internal_link( $url, $home_url, $indexable, $is_image = false, $image_id = 0 ) {
303 $parsed_url = \wp_parse_url( $url );
304 $link_type = $this->url_helper->get_link_type( $parsed_url, $home_url, $is_image );
305
306 /**
307 * ORM representing a link in the SEO Links table.
308 *
309 * @var SEO_Links $model
310 */
311 $model = $this->seo_links_repository->query()->create(
312 [
313 'url' => $url,
314 'type' => $link_type,
315 'indexable_id' => $indexable->id,
316 'post_id' => $indexable->object_id,
317 ],
318 );
319
320 $model->parsed_url = $parsed_url;
321
322 if ( $model->type === SEO_Links::TYPE_INTERNAL ) {
323 $permalink = $this->build_permalink( $url, $home_url );
324
325 return $this->enhance_link_from_indexable( $model, $permalink );
326 }
327
328 if ( $model->type === SEO_Links::TYPE_INTERNAL_IMAGE ) {
329 $permalink = $this->build_permalink( $url, $home_url );
330
331 /** The `wpseo_force_creating_and_using_attachment_indexables` filter is documented in indexable-link-builder.php */
332 if ( ! $this->options_helper->get( 'disable-attachment' ) || \apply_filters( 'wpseo_force_creating_and_using_attachment_indexables', false ) ) {
333 $model = $this->enhance_link_from_indexable( $model, $permalink );
334 }
335 else {
336 $target_post_id = ( $image_id !== 0 ) ? $image_id : WPSEO_Image_Utils::get_attachment_by_url( $permalink );
337
338 if ( ! empty( $target_post_id ) ) {
339 $model->target_post_id = $target_post_id;
340 }
341 }
342
343 if ( $model->target_post_id ) {
344 $file = \get_attached_file( $model->target_post_id );
345
346 if ( $file ) {
347 if ( \file_exists( $file ) ) {
348 $model->size = \filesize( $file );
349 }
350 else {
351 $model->size = null;
352 }
353
354 [ , $width, $height ] = \wp_get_attachment_image_src( $model->target_post_id, 'full' );
355 $model->width = $width;
356 $model->height = $height;
357 }
358 else {
359 $model->width = 0;
360 $model->height = 0;
361 $model->size = 0;
362 }
363 }
364 }
365
366 return $model;
367 }
368
369 /**
370 * Enhances the link model with information from its indexable.
371 *
372 * @param SEO_Links $model The link's model.
373 * @param string $permalink The link's permalink.
374 *
375 * @return SEO_Links The enhanced link model.
376 */
377 protected function enhance_link_from_indexable( $model, $permalink ) {
378 $target = $this->indexable_repository->find_by_permalink( $permalink );
379
380 if ( ! $target ) {
381 // If target indexable cannot be found, create one based on the post's post ID.
382 $post_id = $this->get_post_id( $model->type, $permalink );
383 if ( $post_id && $post_id !== 0 ) {
384 $target = $this->indexable_repository->find_by_id_and_type( $post_id, 'post' );
385 }
386 }
387
388 if ( ! $target ) {
389 return $model;
390 }
391
392 $model->target_indexable_id = $target->id;
393 if ( $target->object_type === 'post' ) {
394 $model->target_post_id = $target->object_id;
395 }
396
397 if ( $model->target_indexable_id ) {
398 $model->language = $target->language;
399 $model->region = $target->region;
400 }
401
402 return $model;
403 }
404
405 /**
406 * Builds the link's permalink.
407 *
408 * @param string $url The url of the link.
409 * @param array $home_url The home url, as parsed by wp_parse_url.
410 *
411 * @return string The link's permalink.
412 */
413 protected function build_permalink( $url, $home_url ) {
414 $permalink = $this->get_permalink( $url, $home_url );
415
416 if ( $this->url_helper->is_relative( $permalink ) ) {
417 // 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.
418 $permalink = $this->url_helper->ensure_absolute_url( \user_trailingslashit( $permalink ) );
419 }
420
421 return $permalink;
422 }
423
424 /**
425 * Filters out links that point to the same page with a fragment or query.
426 *
427 * @param SEO_Links $link The link.
428 * @param array $current_url The url of the page the link is on, as parsed by wp_parse_url.
429 *
430 * @return bool Whether or not the link should be filtered.
431 */
432 protected function filter_link( SEO_Links $link, $current_url ) {
433 $url = $link->parsed_url;
434
435 // Always keep external links.
436 if ( $link->type === SEO_Links::TYPE_EXTERNAL ) {
437 return true;
438 }
439
440 // Always keep links with an empty path or pointing to other pages.
441 if ( isset( $url['path'] ) ) {
442 return empty( $url['path'] ) || $url['path'] !== $current_url['path'];
443 }
444
445 // Only keep links to the current page without a fragment or query.
446 return ( ! isset( $url['fragment'] ) && ! isset( $url['query'] ) );
447 }
448
449 /**
450 * Updates the link counts for related indexables.
451 *
452 * @param Indexable $indexable The indexable.
453 * @param SEO_Links[] $links The link models.
454 *
455 * @return void
456 */
457 protected function update_related_indexables( $indexable, $links ) {
458 // Old links were only stored by post id, so remove all old seo links for this post that have no indexable id.
459 // This can be removed if we ever fully clear all seo links.
460 if ( $indexable->object_type === 'post' ) {
461 $this->seo_links_repository->delete_all_by_post_id_where_indexable_id_null( $indexable->object_id );
462 }
463
464 $updated_indexable_ids = [];
465 $old_links = $this->seo_links_repository->find_all_by_indexable_id( $indexable->id );
466
467 $links_to_remove = $this->links_diff( $old_links, $links );
468 $links_to_add = $this->links_diff( $links, $old_links );
469
470 if ( ! empty( $links_to_remove ) ) {
471 $this->seo_links_repository->delete_many_by_id( \wp_list_pluck( $links_to_remove, 'id' ) );
472 }
473
474 if ( ! empty( $links_to_add ) ) {
475 $this->seo_links_repository->insert_many( $links_to_add );
476 }
477
478 foreach ( $links_to_add as $link ) {
479 if ( $link->target_indexable_id ) {
480 $updated_indexable_ids[] = $link->target_indexable_id;
481 }
482 }
483 foreach ( $links_to_remove as $link ) {
484 if ( $link->target_indexable_id ) {
485 $updated_indexable_ids[] = $link->target_indexable_id;
486 }
487 }
488
489 $this->update_incoming_links_for_related_indexables( $updated_indexable_ids );
490 }
491
492 /**
493 * Creates a diff between two arrays of SEO links, based on urls.
494 *
495 * @param SEO_Links[] $links_a The array to compare.
496 * @param SEO_Links[] $links_b The array to compare against.
497 *
498 * @return SEO_Links[] Links that are in $links_a, but not in $links_b.
499 */
500 protected function links_diff( $links_a, $links_b ) {
501 return \array_udiff(
502 $links_a,
503 $links_b,
504 static function ( SEO_Links $link_a, SEO_Links $link_b ) {
505 return \strcmp( $link_a->url, $link_b->url );
506 },
507 );
508 }
509
510 /**
511 * Returns the number of internal links in an array of link models.
512 *
513 * @param SEO_Links[] $links The link models.
514 *
515 * @return int The number of internal links.
516 */
517 protected function get_internal_link_count( $links ) {
518 $internal_link_count = 0;
519
520 foreach ( $links as $link ) {
521 if ( $link->type === SEO_Links::TYPE_INTERNAL ) {
522 ++$internal_link_count;
523 }
524 }
525
526 return $internal_link_count;
527 }
528
529 /**
530 * Returns a cleaned permalink for a given link.
531 *
532 * @param string $link The raw URL.
533 * @param array $home_url The home URL, as parsed by wp_parse_url.
534 *
535 * @return string The cleaned permalink.
536 */
537 protected function get_permalink( $link, $home_url ) {
538 // Get rid of the #anchor.
539 $url_split = \explode( '#', $link );
540 $link = $url_split[0];
541
542 // Get rid of URL ?query=string.
543 $url_split = \explode( '?', $link );
544 $link = $url_split[0];
545
546 // Set the correct URL scheme.
547 $link = \set_url_scheme( $link, $home_url['scheme'] );
548
549 // Add 'www.' if it is absent and should be there.
550 if ( \strpos( $home_url['host'], 'www.' ) === 0 && \strpos( $link, '://www.' ) === false ) {
551 $link = \str_replace( '://', '://www.', $link );
552 }
553
554 // Strip 'www.' if it is present and shouldn't be.
555 if ( \strpos( $home_url['host'], 'www.' ) !== 0 ) {
556 $link = \str_replace( '://www.', '://', $link );
557 }
558
559 return $link;
560 }
561
562 /**
563 * Updates incoming link counts for related indexables.
564 *
565 * @param int[] $related_indexable_ids The IDs of all related indexables.
566 *
567 * @return void
568 */
569 protected function update_incoming_links_for_related_indexables( $related_indexable_ids ) {
570 if ( empty( $related_indexable_ids ) ) {
571 return;
572 }
573
574 $counts = $this->seo_links_repository->get_incoming_link_counts_for_indexable_ids( $related_indexable_ids );
575
576 /**
577 * Fires to signal that incoming link counts for related indexables were updated.
578 *
579 * @param int[] $related_indexable_ids The related indexable Ids to this link change.
580 *
581 * @internal
582 */
583 \do_action( 'wpseo_related_indexables_incoming_links_updated', $related_indexable_ids );
584
585 foreach ( $counts as $count ) {
586 $this->indexable_repository->update_incoming_link_count( $count['target_indexable_id'], $count['incoming'] );
587 }
588 }
589
590 /**
591 * Updates the image ids when the indexable images are marked as first content image.
592 *
593 * @param Indexable $indexable The indexable to change.
594 * @param array<string|int> $images The image array.
595 *
596 * @return void
597 */
598 public function update_first_content_image( Indexable $indexable, array $images ): void {
599 $current_open_graph_image = $indexable->open_graph_image;
600 $current_twitter_image = $indexable->twitter_image;
601
602 $first_content_image_url = \key( $images );
603 $first_content_image_id = \current( $images );
604
605 if ( $indexable->open_graph_image_source === 'first-content-image' && $current_open_graph_image === $first_content_image_url && ! empty( $first_content_image_id ) ) {
606 $indexable->open_graph_image_id = $first_content_image_id;
607 }
608 if ( $indexable->twitter_image_source === 'first-content-image' && $current_twitter_image === $first_content_image_url && ! empty( $first_content_image_id ) ) {
609 $indexable->twitter_image_id = $first_content_image_id;
610 }
611 }
612 }
613