plugin updates
This commit is contained in:
@@ -1,404 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Yoast\WP\SEO\Premium\Actions;
|
||||
|
||||
use RuntimeException;
|
||||
use WP_User;
|
||||
use WPSEO_Addon_Manager;
|
||||
use Yoast\WP\SEO\Helpers\Options_Helper;
|
||||
use Yoast\WP\SEO\Helpers\User_Helper;
|
||||
use Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Bad_Request_Exception;
|
||||
use Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Forbidden_Exception;
|
||||
use Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Internal_Server_Error_Exception;
|
||||
use Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Not_Found_Exception;
|
||||
use Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Payment_Required_Exception;
|
||||
use Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Request_Timeout_Exception;
|
||||
use Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Service_Unavailable_Exception;
|
||||
use Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Too_Many_Requests_Exception;
|
||||
use Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Unauthorized_Exception;
|
||||
use Yoast\WP\SEO\Premium\Helpers\AI_Generator_Helper;
|
||||
|
||||
/**
|
||||
* Handles the actual requests to our API endpoints.
|
||||
*/
|
||||
class AI_Generator_Action {
|
||||
|
||||
/**
|
||||
* The AI_Generator helper.
|
||||
*
|
||||
* @var AI_Generator_Helper
|
||||
*/
|
||||
protected $ai_generator_helper;
|
||||
|
||||
/**
|
||||
* The Options helper.
|
||||
*
|
||||
* @var Options_Helper
|
||||
*/
|
||||
protected $options_helper;
|
||||
|
||||
/**
|
||||
* The User helper.
|
||||
*
|
||||
* @var User_Helper
|
||||
*/
|
||||
protected $user_helper;
|
||||
|
||||
/**
|
||||
* The add-on manager.
|
||||
*
|
||||
* @var WPSEO_Addon_Manager
|
||||
*/
|
||||
private $addon_manager;
|
||||
|
||||
/**
|
||||
* AI_Generator_Action constructor.
|
||||
*
|
||||
* @param AI_Generator_Helper $ai_generator_helper The AI_Generator helper.
|
||||
* @param Options_Helper $options_helper The Options helper.
|
||||
* @param User_Helper $user_helper The User helper.
|
||||
* @param WPSEO_Addon_Manager $addon_manager The add-on manager.
|
||||
*/
|
||||
public function __construct(
|
||||
AI_Generator_Helper $ai_generator_helper,
|
||||
Options_Helper $options_helper,
|
||||
User_Helper $user_helper,
|
||||
WPSEO_Addon_Manager $addon_manager
|
||||
) {
|
||||
$this->ai_generator_helper = $ai_generator_helper;
|
||||
$this->options_helper = $options_helper;
|
||||
$this->user_helper = $user_helper;
|
||||
$this->addon_manager = $addon_manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests a new set of JWT tokens.
|
||||
*
|
||||
* Requests a new JWT access and refresh token for a user from the Yoast AI Service and stores it in the database
|
||||
* under usermeta. The storing of the token happens in a HTTP callback that is triggered by this request.
|
||||
*
|
||||
* @param WP_User $user The WP user.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @throws Bad_Request_Exception Bad_Request_Exception.
|
||||
* @throws Forbidden_Exception Forbidden_Exception.
|
||||
* @throws Internal_Server_Error_Exception Internal_Server_Error_Exception.
|
||||
* @throws Not_Found_Exception Not_Found_Exception.
|
||||
* @throws Payment_Required_Exception Payment_Required_Exception.
|
||||
* @throws Request_Timeout_Exception Request_Timeout_Exception.
|
||||
* @throws Service_Unavailable_Exception Service_Unavailable_Exception.
|
||||
* @throws Too_Many_Requests_Exception Too_Many_Requests_Exception.
|
||||
* @throws Unauthorized_Exception Unauthorized_Exception.
|
||||
*/
|
||||
public function token_request( WP_User $user ): void {
|
||||
// Ensure the user has given consent.
|
||||
if ( $this->user_helper->get_meta( $user->ID, '_yoast_wpseo_ai_consent', true ) !== '1' ) {
|
||||
throw $this->handle_consent_revoked( $user->ID );
|
||||
}
|
||||
|
||||
// Generate a verification code and store it in the database.
|
||||
$code_verifier = $this->ai_generator_helper->generate_code_verifier( $user );
|
||||
$this->ai_generator_helper->set_code_verifier( $user->ID, $code_verifier );
|
||||
|
||||
$request_body = [
|
||||
'service' => 'openai',
|
||||
'code_challenge' => \hash( 'sha256', $code_verifier ),
|
||||
'license_site_url' => $this->ai_generator_helper->get_license_url(),
|
||||
'user_id' => (string) $user->ID,
|
||||
'callback_url' => $this->ai_generator_helper->get_callback_url(),
|
||||
'refresh_callback_url' => $this->ai_generator_helper->get_refresh_callback_url(),
|
||||
];
|
||||
|
||||
$this->ai_generator_helper->request( '/token/request', $request_body );
|
||||
|
||||
// The callback saves the metadata. Because that is in another session, we need to delete the current cache here. Or we may get the old token.
|
||||
\wp_cache_delete( $user->ID, 'user_meta' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the JWT access token.
|
||||
*
|
||||
* Refreshes a stored JWT access token for a user with the Yoast AI Service and stores it in the database under
|
||||
* usermeta. The storing of the token happens in a HTTP callback that is triggered by this request.
|
||||
*
|
||||
* @param WP_User $user The WP user.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @throws Bad_Request_Exception Bad_Request_Exception.
|
||||
* @throws Forbidden_Exception Forbidden_Exception.
|
||||
* @throws Internal_Server_Error_Exception Internal_Server_Error_Exception.
|
||||
* @throws Not_Found_Exception Not_Found_Exception.
|
||||
* @throws Payment_Required_Exception Payment_Required_Exception.
|
||||
* @throws Request_Timeout_Exception Request_Timeout_Exception.
|
||||
* @throws Service_Unavailable_Exception Service_Unavailable_Exception.
|
||||
* @throws Too_Many_Requests_Exception Too_Many_Requests_Exception.
|
||||
* @throws Unauthorized_Exception Unauthorized_Exception.
|
||||
* @throws RuntimeException Unable to retrieve the refresh token.
|
||||
*/
|
||||
public function token_refresh( WP_User $user ): void {
|
||||
$refresh_jwt = $this->ai_generator_helper->get_refresh_token( $user->ID );
|
||||
|
||||
// Generate a verification code and store it in the database.
|
||||
$code_verifier = $this->ai_generator_helper->generate_code_verifier( $user );
|
||||
$this->ai_generator_helper->set_code_verifier( $user->ID, $code_verifier );
|
||||
|
||||
$request_body = [
|
||||
'code_challenge' => \hash( 'sha256', $code_verifier ),
|
||||
];
|
||||
$request_headers = [
|
||||
'Authorization' => "Bearer $refresh_jwt",
|
||||
];
|
||||
|
||||
$this->ai_generator_helper->request( '/token/refresh', $request_body, $request_headers );
|
||||
|
||||
// The callback saves the metadata. Because that is in another session, we need to delete the current cache here. Or we may get the old token.
|
||||
\wp_cache_delete( $user->ID, 'user_meta' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback function that will be invoked by our API.
|
||||
*
|
||||
* @param string $access_jwt The access JWT.
|
||||
* @param string $refresh_jwt The refresh JWT.
|
||||
* @param string $code_challenge The verification code.
|
||||
* @param int $user_id The user ID.
|
||||
*
|
||||
* @return string The code verifier.
|
||||
*
|
||||
* @throws Unauthorized_Exception Unauthorized_Exception.
|
||||
*/
|
||||
public function callback(
|
||||
string $access_jwt,
|
||||
string $refresh_jwt,
|
||||
string $code_challenge,
|
||||
int $user_id
|
||||
): string {
|
||||
try {
|
||||
$code_verifier = $this->ai_generator_helper->get_code_verifier( $user_id );
|
||||
} catch ( RuntimeException $exception ) {
|
||||
throw new Unauthorized_Exception( 'Unauthorized' );
|
||||
}
|
||||
|
||||
if ( $code_challenge !== \hash( 'sha256', $code_verifier ) ) {
|
||||
throw new Unauthorized_Exception( 'Unauthorized' );
|
||||
}
|
||||
$this->user_helper->update_meta( $user_id, '_yoast_wpseo_ai_generator_access_jwt', $access_jwt );
|
||||
$this->user_helper->update_meta( $user_id, '_yoast_wpseo_ai_generator_refresh_jwt', $refresh_jwt );
|
||||
$this->ai_generator_helper->delete_code_verifier( $user_id );
|
||||
|
||||
return $code_verifier;
|
||||
}
|
||||
|
||||
// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- PHPCS doesn't take into account exceptions thrown in called methods.
|
||||
|
||||
/**
|
||||
* Action used to generate suggestions through AI.
|
||||
*
|
||||
* @param WP_User $user The WP user.
|
||||
* @param string $suggestion_type The type of the requested suggestion.
|
||||
* @param string $prompt_content The excerpt taken from the post.
|
||||
* @param string $focus_keyphrase The focus keyphrase associated to the post.
|
||||
* @param string $language The language of the post.
|
||||
* @param string $platform The platform the post is intended for.
|
||||
* @param bool $retry_on_unauthorized Whether to retry when unauthorized (mechanism to retry once).
|
||||
*
|
||||
* @return array The suggestions.
|
||||
*
|
||||
* @throws Bad_Request_Exception Bad_Request_Exception.
|
||||
* @throws Forbidden_Exception Forbidden_Exception.
|
||||
* @throws Internal_Server_Error_Exception Internal_Server_Error_Exception.
|
||||
* @throws Not_Found_Exception Not_Found_Exception.
|
||||
* @throws Payment_Required_Exception Payment_Required_Exception.
|
||||
* @throws Request_Timeout_Exception Request_Timeout_Exception.
|
||||
* @throws Service_Unavailable_Exception Service_Unavailable_Exception.
|
||||
* @throws Too_Many_Requests_Exception Too_Many_Requests_Exception.
|
||||
* @throws Unauthorized_Exception Unauthorized_Exception.
|
||||
* @throws RuntimeException Unable to retrieve the access token.
|
||||
*/
|
||||
public function get_suggestions(
|
||||
WP_User $user,
|
||||
string $suggestion_type,
|
||||
string $prompt_content,
|
||||
string $focus_keyphrase,
|
||||
string $language,
|
||||
string $platform,
|
||||
bool $retry_on_unauthorized = true
|
||||
): array {
|
||||
$token = $this->get_or_request_access_token( $user );
|
||||
|
||||
$request_body = [
|
||||
'service' => 'openai',
|
||||
'user_id' => (string) $user->ID,
|
||||
'subject' => [
|
||||
'content' => $prompt_content,
|
||||
'focus_keyphrase' => $focus_keyphrase,
|
||||
'language' => $language,
|
||||
'platform' => $platform,
|
||||
],
|
||||
];
|
||||
$request_headers = [
|
||||
'Authorization' => "Bearer $token",
|
||||
];
|
||||
|
||||
try {
|
||||
$response = $this->ai_generator_helper->request( "/openai/suggestions/$suggestion_type", $request_body, $request_headers );
|
||||
} catch ( Unauthorized_Exception $exception ) {
|
||||
// Delete the stored JWT tokens, as they appear to be no longer valid.
|
||||
$this->user_helper->delete_meta( $user->ID, '_yoast_wpseo_ai_generator_access_jwt' );
|
||||
$this->user_helper->delete_meta( $user->ID, '_yoast_wpseo_ai_generator_refresh_jwt' );
|
||||
|
||||
if ( ! $retry_on_unauthorized ) {
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
// Try again once more by fetching a new set of tokens and trying the suggestions endpoint again.
|
||||
return $this->get_suggestions( $user, $suggestion_type, $prompt_content, $focus_keyphrase, $language, $platform, false );
|
||||
} catch ( Forbidden_Exception $exception ) {
|
||||
// Follow the API in the consent being revoked (Use case: user sent an e-mail to revoke?).
|
||||
throw $this->handle_consent_revoked( $user->ID, $exception->getCode() );
|
||||
}
|
||||
|
||||
return $this->ai_generator_helper->build_suggestions_array( $response );
|
||||
}
|
||||
|
||||
// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
|
||||
|
||||
/**
|
||||
* Stores the consent given or revoked by the user.
|
||||
*
|
||||
* @param int $user_id The user ID.
|
||||
* @param bool $consent Whether the consent has been given.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @throws Bad_Request_Exception Bad_Request_Exception.
|
||||
* @throws Internal_Server_Error_Exception Internal_Server_Error_Exception.
|
||||
* @throws Not_Found_Exception Not_Found_Exception.
|
||||
* @throws Payment_Required_Exception Payment_Required_Exception.
|
||||
* @throws Request_Timeout_Exception Request_Timeout_Exception.
|
||||
* @throws Service_Unavailable_Exception Service_Unavailable_Exception.
|
||||
* @throws Too_Many_Requests_Exception Too_Many_Requests_Exception.
|
||||
* @throws RuntimeException Unable to retrieve the access token.
|
||||
*/
|
||||
public function consent( int $user_id, bool $consent ): void {
|
||||
if ( $consent ) {
|
||||
// Store the consent at user level.
|
||||
$this->user_helper->update_meta( $user_id, '_yoast_wpseo_ai_consent', true );
|
||||
}
|
||||
else {
|
||||
$this->token_invalidate( $user_id );
|
||||
|
||||
// Delete the consent at user level.
|
||||
$this->user_helper->delete_meta( $user_id, '_yoast_wpseo_ai_consent' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Busts the subscription cache.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function bust_subscription_cache(): void {
|
||||
$this->addon_manager->remove_site_information_transients();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the access token.
|
||||
*
|
||||
* @param WP_User $user The WP user.
|
||||
*
|
||||
* @return string The access token.
|
||||
*
|
||||
* @throws Bad_Request_Exception Bad_Request_Exception.
|
||||
* @throws Forbidden_Exception Forbidden_Exception.
|
||||
* @throws Internal_Server_Error_Exception Internal_Server_Error_Exception.
|
||||
* @throws Not_Found_Exception Not_Found_Exception.
|
||||
* @throws Payment_Required_Exception Payment_Required_Exception.
|
||||
* @throws Request_Timeout_Exception Request_Timeout_Exception.
|
||||
* @throws Service_Unavailable_Exception Service_Unavailable_Exception.
|
||||
* @throws Too_Many_Requests_Exception Too_Many_Requests_Exception.
|
||||
* @throws Unauthorized_Exception Unauthorized_Exception.
|
||||
* @throws RuntimeException Unable to retrieve the access or refresh token.
|
||||
*/
|
||||
private function get_or_request_access_token( WP_User $user ): string {
|
||||
$access_jwt = $this->user_helper->get_meta( $user->ID, '_yoast_wpseo_ai_generator_access_jwt', true );
|
||||
if ( ! \is_string( $access_jwt ) || $access_jwt === '' ) {
|
||||
$this->token_request( $user );
|
||||
$access_jwt = $this->ai_generator_helper->get_access_token( $user->ID );
|
||||
}
|
||||
elseif ( $this->ai_generator_helper->has_token_expired( $access_jwt ) ) {
|
||||
try {
|
||||
$this->token_refresh( $user );
|
||||
} catch ( Unauthorized_Exception $exception ) {
|
||||
$this->token_request( $user );
|
||||
} catch ( Forbidden_Exception $exception ) {
|
||||
// Follow the API in the consent being revoked (Use case: user sent an e-mail to revoke?).
|
||||
throw $this->handle_consent_revoked( $user->ID, $exception->getCode() );
|
||||
}
|
||||
$access_jwt = $this->ai_generator_helper->get_access_token( $user->ID );
|
||||
}
|
||||
|
||||
return $access_jwt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidates the access token.
|
||||
*
|
||||
* @param string $user_id The user ID.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @throws Bad_Request_Exception Bad_Request_Exception.
|
||||
* @throws Internal_Server_Error_Exception Internal_Server_Error_Exception.
|
||||
* @throws Not_Found_Exception Not_Found_Exception.
|
||||
* @throws Payment_Required_Exception Payment_Required_Exception.
|
||||
* @throws Request_Timeout_Exception Request_Timeout_Exception.
|
||||
* @throws Service_Unavailable_Exception Service_Unavailable_Exception.
|
||||
* @throws Too_Many_Requests_Exception Too_Many_Requests_Exception.
|
||||
* @throws RuntimeException Unable to retrieve the access token.
|
||||
*/
|
||||
private function token_invalidate( string $user_id ): void {
|
||||
try {
|
||||
$access_jwt = $this->ai_generator_helper->get_access_token( $user_id );
|
||||
} catch ( RuntimeException $e ) {
|
||||
$access_jwt = '';
|
||||
}
|
||||
|
||||
$request_body = [
|
||||
'user_id' => (string) $user_id,
|
||||
];
|
||||
$request_headers = [
|
||||
'Authorization' => "Bearer $access_jwt",
|
||||
];
|
||||
|
||||
try {
|
||||
$this->ai_generator_helper->request( '/token/invalidate', $request_body, $request_headers );
|
||||
} catch ( Unauthorized_Exception | Forbidden_Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Reason: Ignored on purpose.
|
||||
// We do nothing in this case, we trust nonce verification and try to remove the user data anyway.
|
||||
// I.e. we fallthrough to the same logic as if we got a 200 OK.
|
||||
}
|
||||
|
||||
// Delete the stored JWT tokens.
|
||||
$this->user_helper->delete_meta( $user_id, '_yoast_wpseo_ai_generator_access_jwt' );
|
||||
$this->user_helper->delete_meta( $user_id, '_yoast_wpseo_ai_generator_refresh_jwt' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles consent revoked.
|
||||
*
|
||||
* By deleting the consent user metadata from the database.
|
||||
* And then throwing a Forbidden_Exception.
|
||||
*
|
||||
* @param int $user_id The user ID.
|
||||
* @param int $status_code The status code. Defaults to 403.
|
||||
*
|
||||
* @return Forbidden_Exception The Forbidden_Exception.
|
||||
*/
|
||||
private function handle_consent_revoked( int $user_id, int $status_code = 403 ): Forbidden_Exception {
|
||||
$this->user_helper->delete_meta( $user_id, '_yoast_wpseo_ai_consent' );
|
||||
|
||||
return new Forbidden_Exception( 'CONSENT_REVOKED', $status_code );
|
||||
}
|
||||
}
|
||||
@@ -1,607 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Yoast\WP\SEO\Premium\Actions;
|
||||
|
||||
use WP_Query;
|
||||
use WPSEO_Premium_Prominent_Words_Support;
|
||||
use Yoast\WP\SEO\Models\Indexable;
|
||||
use Yoast\WP\SEO\Premium\Helpers\Prominent_Words_Helper;
|
||||
use Yoast\WP\SEO\Premium\Repositories\Prominent_Words_Repository;
|
||||
use Yoast\WP\SEO\Repositories\Indexable_Repository;
|
||||
use Yoast\WP\SEO\Repositories\SEO_Links_Repository;
|
||||
|
||||
/**
|
||||
* Handles the actual requests to the prominent words endpoints.
|
||||
*/
|
||||
class Link_Suggestions_Action {
|
||||
|
||||
/**
|
||||
* The amount of indexables to retrieve in one go
|
||||
* when generating internal linking suggestions.
|
||||
*/
|
||||
public const BATCH_SIZE = 1000;
|
||||
|
||||
/**
|
||||
* The repository to retrieve prominent words from.
|
||||
*
|
||||
* @var Prominent_Words_Repository
|
||||
*/
|
||||
protected $prominent_words_repository;
|
||||
|
||||
/**
|
||||
* The repository to retrieve indexables from.
|
||||
*
|
||||
* @var Indexable_Repository
|
||||
*/
|
||||
protected $indexable_repository;
|
||||
|
||||
/**
|
||||
* The repository to retrieve links from.
|
||||
*
|
||||
* @var SEO_Links_Repository
|
||||
*/
|
||||
protected $links_repository;
|
||||
|
||||
/**
|
||||
* Contains helper functions for calculating with and comparing prominent words.
|
||||
*
|
||||
* @var Prominent_Words_Helper
|
||||
*/
|
||||
protected $prominent_words_helper;
|
||||
|
||||
/**
|
||||
* Represents the prominent words support class.
|
||||
*
|
||||
* @var WPSEO_Premium_Prominent_Words_Support
|
||||
*/
|
||||
protected $prominent_words_support;
|
||||
|
||||
/**
|
||||
* Link_Suggestions_Service constructor.
|
||||
*
|
||||
* @param Prominent_Words_Repository $prominent_words_repository The repository to retrieve prominent words from.
|
||||
* @param Indexable_Repository $indexable_repository The repository to retrieve indexables from.
|
||||
* @param Prominent_Words_Helper $prominent_words_helper Class with helper methods for prominent words.
|
||||
* @param WPSEO_Premium_Prominent_Words_Support $prominent_words_support The prominent words support class.
|
||||
* @param SEO_Links_Repository $links_repository The repository to retrieve links from.
|
||||
*/
|
||||
public function __construct(
|
||||
Prominent_Words_Repository $prominent_words_repository,
|
||||
Indexable_Repository $indexable_repository,
|
||||
Prominent_Words_Helper $prominent_words_helper,
|
||||
WPSEO_Premium_Prominent_Words_Support $prominent_words_support,
|
||||
SEO_Links_Repository $links_repository
|
||||
) {
|
||||
$this->prominent_words_repository = $prominent_words_repository;
|
||||
$this->indexable_repository = $indexable_repository;
|
||||
$this->prominent_words_helper = $prominent_words_helper;
|
||||
$this->prominent_words_support = $prominent_words_support;
|
||||
$this->links_repository = $links_repository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggests a list of links, based on the given array of prominent words.
|
||||
*
|
||||
* @param array $words_from_request The prominent words as an array mapping words to weights.
|
||||
* @param int $limit The maximum number of link suggestions to retrieve.
|
||||
* @param int $object_id The object id for the current indexable.
|
||||
* @param string $object_type The object type for the current indexable.
|
||||
* @param bool $include_existing_links Optional. Whether or not to include existing links, defaults to true.
|
||||
* @param array $post_type Optional. The list of post types where suggestions may come from.
|
||||
* @param bool $only_include_public Optional. Only include public indexables, defaults to false.
|
||||
*
|
||||
* @return array Links for the post that are suggested.
|
||||
*/
|
||||
public function get_suggestions( $words_from_request, $limit, $object_id, $object_type, $include_existing_links = true, $post_type = [], $only_include_public = false ) {
|
||||
$current_indexable_id = null;
|
||||
$current_indexable = $this->indexable_repository->find_by_id_and_type( $object_id, $object_type );
|
||||
if ( $current_indexable ) {
|
||||
$current_indexable_id = $current_indexable->id;
|
||||
}
|
||||
|
||||
/*
|
||||
* Gets best suggestions (returns a sorted array [$indexable_id => score]).
|
||||
* The indexables are processed in batches of 1000 indexables each.
|
||||
*/
|
||||
$suggestions_scores = $this->retrieve_suggested_indexable_ids( $words_from_request, $limit, self::BATCH_SIZE, $current_indexable_id, $include_existing_links, $post_type, $only_include_public );
|
||||
|
||||
$indexable_ids = \array_keys( $suggestions_scores );
|
||||
|
||||
// Return the empty list if no suggestions have been found.
|
||||
if ( empty( $indexable_ids ) ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Retrieve indexables for suggestions.
|
||||
$suggestions_indexables = $this->indexable_repository->query()->where_id_in( $indexable_ids )->find_many();
|
||||
|
||||
/**
|
||||
* Filter 'wpseo_link_suggestions_indexables' - Allow filtering link suggestions indexable objects.
|
||||
*
|
||||
* @param array $suggestions An array of suggestion indexables that can be filtered.
|
||||
* @param int $object_id The object id for the current indexable.
|
||||
* @param string $object_type The object type for the current indexable.
|
||||
*/
|
||||
$suggestions_indexables = \apply_filters( 'wpseo_link_suggestions_indexables', $suggestions_indexables, $object_id, $object_type );
|
||||
|
||||
// Create suggestions objects.
|
||||
return $this->create_suggestions( $suggestions_indexables, $suggestions_scores );
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggests a list of links, based on the given array of prominent words.
|
||||
*
|
||||
* @param int $id The object id for the current indexable.
|
||||
* @param int $limit The maximum number of link suggestions to retrieve.
|
||||
* @param bool $include_existing_links Optional. Whether or not to include existing links, defaults to true.
|
||||
*
|
||||
* @return array Links for the post that are suggested.
|
||||
*/
|
||||
public function get_indexable_suggestions_for_indexable( $id, $limit, $include_existing_links = true ) {
|
||||
$weighted_words = [];
|
||||
$prominent_words = $this->prominent_words_repository->query()
|
||||
->where( 'indexable_id', $id )
|
||||
->find_array();
|
||||
foreach ( $prominent_words as $prominent_word ) {
|
||||
$weighted_words[ $prominent_word['stem'] ] = $prominent_word['weight'];
|
||||
}
|
||||
|
||||
/*
|
||||
* Gets best suggestions (returns a sorted array [$indexable_id => score]).
|
||||
* The indexables are processed in batches of 1000 indexables each.
|
||||
*/
|
||||
$suggestions_scores = $this->retrieve_suggested_indexable_ids( $weighted_words, $limit, self::BATCH_SIZE, $id, $include_existing_links );
|
||||
|
||||
$indexable_ids = \array_keys( $suggestions_scores );
|
||||
|
||||
// Return the empty list if no suggestions have been found.
|
||||
if ( empty( $indexable_ids ) ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Retrieve indexables for suggestions.
|
||||
return $this->indexable_repository->query()->where_id_in( $indexable_ids )->find_array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the titles of the posts with the given IDs.
|
||||
*
|
||||
* @param array $post_ids The IDs of the posts to retrieve the titles of.
|
||||
*
|
||||
* @return array An array mapping post ID to title.
|
||||
*/
|
||||
protected function retrieve_posts( $post_ids ) {
|
||||
$query = new WP_Query(
|
||||
[
|
||||
'post_type' => $this->prominent_words_support->get_supported_post_types(),
|
||||
'post__in' => $post_ids,
|
||||
'posts_per_page' => \count( $post_ids ),
|
||||
]
|
||||
);
|
||||
$posts = $query->get_posts();
|
||||
|
||||
$post_data = [];
|
||||
|
||||
foreach ( $posts as $post ) {
|
||||
$post_data[ $post->ID ] = [
|
||||
'title' => $post->post_title,
|
||||
];
|
||||
}
|
||||
|
||||
return $post_data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the names of the terms with the given IDs.
|
||||
*
|
||||
* @param Indexable[] $indexables The indexables to retrieve titles for.
|
||||
*
|
||||
* @return array An array mapping term ID to title.
|
||||
*/
|
||||
protected function retrieve_terms( $indexables ) {
|
||||
$data = [];
|
||||
foreach ( $indexables as $indexable ) {
|
||||
if ( $indexable->object_type !== 'term' ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$term = \get_term_by( 'term_id', $indexable->object_id, $indexable->object_sub_type );
|
||||
|
||||
$data[ $indexable->object_id ] = [
|
||||
'title' => $term->name,
|
||||
];
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the titles of the given array of indexables.
|
||||
*
|
||||
* @param Indexable[] $indexables An array of indexables for which to retrieve the titles.
|
||||
*
|
||||
* @return array A two-dimensional array mapping object type and object id to title.
|
||||
*/
|
||||
protected function retrieve_object_titles( $indexables ) {
|
||||
$object_ids = [];
|
||||
|
||||
foreach ( $indexables as $indexable ) {
|
||||
if ( \array_key_exists( $indexable->object_type, $object_ids ) ) {
|
||||
$object_ids[ $indexable->object_type ][] = $indexable->object_id;
|
||||
}
|
||||
else {
|
||||
$object_ids[ $indexable->object_type ] = [ $indexable->object_id ];
|
||||
}
|
||||
}
|
||||
|
||||
$objects = [
|
||||
'post' => [],
|
||||
'term' => [],
|
||||
];
|
||||
|
||||
// At the moment we only support internal linking for posts, so we only need the post titles.
|
||||
if ( \array_key_exists( 'post', $object_ids ) ) {
|
||||
$objects['post'] = $this->retrieve_posts( $object_ids['post'] );
|
||||
}
|
||||
|
||||
if ( \array_key_exists( 'term', $object_ids ) ) {
|
||||
$objects['term'] = $this->retrieve_terms( $indexables );
|
||||
}
|
||||
|
||||
return $objects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes, for a given indexable, its raw matching score on the request words to match.
|
||||
* In general, higher scores mean better matches.
|
||||
*
|
||||
* @param array $request_data The words to match, as an array containing stems, weights and dfs.
|
||||
* @param array $candidate_data The words to match against, as an array of `Prominent_Words` objects.
|
||||
*
|
||||
* @return float A raw score of the indexable.
|
||||
*/
|
||||
protected function compute_raw_score( $request_data, $candidate_data ) {
|
||||
$raw_score = 0;
|
||||
|
||||
foreach ( $candidate_data as $stem => $candidate_word_data ) {
|
||||
if ( ! \array_key_exists( $stem, $request_data ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$word_from_request_weight = $request_data[ $stem ]['weight'];
|
||||
$word_from_request_df = $request_data[ $stem ]['df'];
|
||||
$candidate_weight = $candidate_word_data['weight'];
|
||||
$candidate_df = $candidate_word_data['df'];
|
||||
|
||||
$tf_idf_word_from_request = $this->prominent_words_helper->compute_tf_idf_score( $word_from_request_weight, $word_from_request_df );
|
||||
$tf_idf_word_from_database = $this->prominent_words_helper->compute_tf_idf_score( $candidate_weight, $candidate_df );
|
||||
|
||||
// Score on this word is the product of the tf-idf scores.
|
||||
$raw_score += ( $tf_idf_word_from_request * $tf_idf_word_from_database );
|
||||
}
|
||||
|
||||
return $raw_score;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines weight data of the request words to their document frequencies. This is needed to calculate
|
||||
* vector length of the request data.
|
||||
*
|
||||
* @param array $request_words An array mapping words to weights.
|
||||
*
|
||||
* @return array An array mapping stems, weights and dfs.
|
||||
*/
|
||||
protected function compose_request_data( $request_words ) {
|
||||
$request_doc_frequencies = $this->prominent_words_repository->count_document_frequencies( \array_keys( $request_words ) );
|
||||
$combined_request_data = [];
|
||||
foreach ( $request_words as $stem => $weight ) {
|
||||
if ( ! isset( $request_doc_frequencies[ $stem ] ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$combined_request_data[ $stem ] = [
|
||||
'weight' => (int) $weight,
|
||||
'df' => $request_doc_frequencies[ $stem ],
|
||||
];
|
||||
}
|
||||
|
||||
return $combined_request_data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the array of prominent words into an array of objects mapping indexable_id to an array
|
||||
* of prominent words associated with this indexable_id, with each prominent word's stem as a key.
|
||||
*
|
||||
* @param array $words The array of prominent words, with indexable_id as one of the keys.
|
||||
*
|
||||
* @return array An array mapping indexable IDs to their prominent words.
|
||||
*/
|
||||
protected function group_words_by_indexable_id( $words ) {
|
||||
$candidates_words_by_indexable_ids = [];
|
||||
foreach ( $words as $word ) {
|
||||
$indexable_id = $word->indexable_id;
|
||||
|
||||
$candidates_words_by_indexable_ids[ $indexable_id ][ $word->stem ] = [
|
||||
'weight' => (int) $word->weight,
|
||||
'df' => (int) $word->df,
|
||||
];
|
||||
}
|
||||
|
||||
return $candidates_words_by_indexable_ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates a matching score for one candidate indexable.
|
||||
*
|
||||
* @param array $request_data An array matching stems from request to their weights and dfs.
|
||||
* @param float $request_vector_length The vector length of the request words.
|
||||
* @param array $candidate_data An array matching stems from the candidate to their weights and dfs.
|
||||
*
|
||||
* @return float A matching score for an indexable.
|
||||
*/
|
||||
protected function calculate_score_for_indexable( $request_data, $request_vector_length, $candidate_data ) {
|
||||
$raw_score = $this->compute_raw_score( $request_data, $candidate_data );
|
||||
$candidate_vector_length = $this->prominent_words_helper->compute_vector_length( $candidate_data );
|
||||
return $this->normalize_score( $raw_score, $candidate_vector_length, $request_vector_length );
|
||||
}
|
||||
|
||||
/**
|
||||
* In the prominent words repository, find a $batch_size of all ProminentWord-IndexableID pairs where
|
||||
* prominent words match the set of stems we are interested in.
|
||||
* Request prominent words for indexables in the batch (including the iDF of all words) to calculate
|
||||
* their vector length later.
|
||||
*
|
||||
* @param array $stems The stems in the request.
|
||||
* @param int $batch_size How many indexables to request in one query.
|
||||
* @param int $page The start of the current batch (in pages).
|
||||
* @param int[] $excluded_ids The indexable IDs to exclude.
|
||||
* @param array $post_type The post types that will be searched.
|
||||
* @param bool $only_include_public If only public indexables are included.
|
||||
*
|
||||
* @return array An array of ProminentWords objects, containing their stem, weight, indexable id,
|
||||
* and document frequency.
|
||||
*/
|
||||
protected function get_candidate_words( $stems, $batch_size, $page, $excluded_ids = [], $post_type = [], $only_include_public = false ) {
|
||||
|
||||
return $this->prominent_words_repository->find_by_list_of_ids(
|
||||
$this->prominent_words_repository->find_ids_by_stems( $stems, $batch_size, $page, $excluded_ids, $post_type, $only_include_public )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* For each candidate indexable, computes their matching score related to the request set of prominent words.
|
||||
* The candidate indexables are analyzed in batches.
|
||||
* After having computed scores for a batch the function saves the best candidates until now.
|
||||
*
|
||||
* @param array $request_words The words to match, as an array mapping words to weights.
|
||||
* @param int $limit The max number of suggestions that should be returned by the function.
|
||||
* @param int $batch_size The number of indexables that should be analyzed in every batch.
|
||||
* @param int|null $current_indexable_id The id for the current indexable.
|
||||
* @param bool $include_existing_links Optional. Whether or not to include existing links, defaults to true.
|
||||
* @param array $post_type Optional. The list of post types where suggestions may come from.
|
||||
* @param bool $only_include_public Optional. Only include public indexables, defaults to false.
|
||||
*
|
||||
* @return array An array mapping indexable IDs to scores. Higher scores mean better matches.
|
||||
*/
|
||||
protected function retrieve_suggested_indexable_ids( $request_words, $limit, $batch_size, $current_indexable_id, $include_existing_links = true, $post_type = [], $only_include_public = false ) {
|
||||
// Combine stems, weights and DFs from request.
|
||||
$request_data = $this->compose_request_data( $request_words );
|
||||
|
||||
// Calculate vector length of the request set (needed for score normalization later).
|
||||
$request_vector_length = $this->prominent_words_helper->compute_vector_length( $request_data );
|
||||
|
||||
// Get all links the post already links to, those shouldn't be suggested.
|
||||
$excluded_indexable_ids = [ $current_indexable_id ];
|
||||
if ( ! $include_existing_links && $current_indexable_id ) {
|
||||
$links = $this->links_repository->query()
|
||||
->distinct()
|
||||
->select( 'indexable_id' )
|
||||
->where( 'target_indexable_id', $current_indexable_id )
|
||||
->find_many();
|
||||
$excluded_indexable_ids = \array_merge( $excluded_indexable_ids, \wp_list_pluck( $links, 'indexable_id' ) );
|
||||
}
|
||||
$excluded_indexable_ids = \array_filter( $excluded_indexable_ids );
|
||||
|
||||
$request_stems = \array_keys( $request_data );
|
||||
$scores = [];
|
||||
$page = 1;
|
||||
|
||||
do {
|
||||
// Retrieve the words of all indexables in this batch that share prominent word stems with request.
|
||||
$candidates_words = $this->get_candidate_words( $request_stems, $batch_size, $page, $excluded_indexable_ids, $post_type, $only_include_public );
|
||||
|
||||
// Transform the prominent words table so that it is indexed by indexable_ids.
|
||||
$candidates_words_by_indexable_ids = $this->group_words_by_indexable_id( $candidates_words );
|
||||
|
||||
$batch_scores_size = 0;
|
||||
|
||||
foreach ( $candidates_words_by_indexable_ids as $id => $candidate_data ) {
|
||||
$scores[ $id ] = $this->calculate_score_for_indexable( $request_data, $request_vector_length, $candidate_data );
|
||||
++$batch_scores_size;
|
||||
}
|
||||
|
||||
// Sort the list of scores and keep only the top $limit of the scores.
|
||||
$scores = $this->get_top_suggestions( $scores, $limit );
|
||||
|
||||
++$page;
|
||||
} while ( $batch_scores_size === $batch_size );
|
||||
|
||||
return $scores;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes the raw score based on the length of the prominent word vectors.
|
||||
*
|
||||
* @param float $raw_score The raw (non-normalized) score.
|
||||
* @param float $vector_length_candidate The vector lengths of the candidate indexable.
|
||||
* @param float $vector_length_request The vector length of the words from the request.
|
||||
*
|
||||
* @return int|float The score, normalized on vector lengths.
|
||||
*/
|
||||
protected function normalize_score( $raw_score, $vector_length_candidate, $vector_length_request ) {
|
||||
$normalizing_factor = ( $vector_length_request * $vector_length_candidate );
|
||||
|
||||
if ( $normalizing_factor === 0.0 ) {
|
||||
// We can't divide by 0, so set the score to 0 instead.
|
||||
return 0;
|
||||
}
|
||||
|
||||
return ( $raw_score / $normalizing_factor );
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts the indexable ids based on the score and returns the top N indexable ids based on a specified limit.
|
||||
* (Returns all indexable ids if there are less indexable ids than specified by the limit.)
|
||||
*
|
||||
* @param array $scores The array matching indexable ids to their scores.
|
||||
* @param int $limit The maximum number of indexables that should be returned.
|
||||
*
|
||||
* @return array The top N indexable ids, sorted from highest to lowest score.
|
||||
*/
|
||||
protected function get_top_suggestions( $scores, $limit ) {
|
||||
// Sort the indexables by descending score.
|
||||
\uasort(
|
||||
$scores,
|
||||
static function ( $score_1, $score_2 ) {
|
||||
if ( $score_1 === $score_2 ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return ( ( $score_1 < $score_2 ) ? 1 : -1 );
|
||||
}
|
||||
);
|
||||
|
||||
// Take the top $limit suggestions, while preserving their ids specified in the keys of the array elements.
|
||||
return \array_slice( $scores, 0, $limit, true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the singular label of the given combination of object type and sub type.
|
||||
*
|
||||
* @param string $object_type An object type. For example 'post' or 'term'.
|
||||
* @param string $object_sub_type An object sub type. For example 'page' or 'category'.
|
||||
*
|
||||
* @return string The singular label of the given combination of object type and sub type,
|
||||
* or the empty string if the singular label does not exist.
|
||||
*/
|
||||
protected function get_sub_type_singular_label( $object_type, $object_sub_type ) {
|
||||
switch ( $object_type ) {
|
||||
case 'post':
|
||||
$post_type = \get_post_type_object( $object_sub_type );
|
||||
if ( $post_type ) {
|
||||
return $post_type->labels->singular_name;
|
||||
}
|
||||
break;
|
||||
case 'term':
|
||||
$taxonomy = \get_taxonomy( $object_sub_type );
|
||||
if ( $taxonomy ) {
|
||||
return $taxonomy->labels->singular_name;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates link suggestion data based on the indexables that should be suggested and the scores for these
|
||||
* indexables.
|
||||
*
|
||||
* @param Indexable[] $indexables The indexables for which to create linking suggestions.
|
||||
* @param array $scores The scores for the linking suggestions.
|
||||
*
|
||||
* @return array The internal linking suggestions.
|
||||
*/
|
||||
protected function create_suggestions( $indexables, $scores ) {
|
||||
$objects = $this->retrieve_object_titles( $indexables );
|
||||
$link_suggestions = [];
|
||||
|
||||
foreach ( $indexables as $indexable ) {
|
||||
if ( ! \array_key_exists( $indexable->object_type, $objects ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Object tied to this indexable. E.g. post, page, term.
|
||||
if ( ! \array_key_exists( $indexable->object_id, $objects[ $indexable->object_type ] ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$link_suggestions[] = [
|
||||
'object_type' => $indexable->object_type,
|
||||
'id' => (int) ( $indexable->object_id ),
|
||||
'title' => $objects[ $indexable->object_type ][ $indexable->object_id ]['title'],
|
||||
'link' => $indexable->permalink,
|
||||
'isCornerstone' => (bool) $indexable->is_cornerstone,
|
||||
'labels' => $this->get_labels( $indexable ),
|
||||
'score' => \round( (float) ( $scores[ $indexable->id ] ), 2 ),
|
||||
];
|
||||
}
|
||||
|
||||
/*
|
||||
* Because the request to the indexables table messes up with the ordering of the suggestions,
|
||||
* we have to sort again.
|
||||
*/
|
||||
$this->sort_suggestions_by_field( $link_suggestions, 'score' );
|
||||
|
||||
$cornerstone_suggestions = $this->filter_suggestions( $link_suggestions, true );
|
||||
$non_cornerstone_suggestions = $this->filter_suggestions( $link_suggestions, false );
|
||||
|
||||
return \array_merge_recursive( [], $cornerstone_suggestions, $non_cornerstone_suggestions );
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the labels for the link suggestion.
|
||||
*
|
||||
* @param Indexable $indexable The indexable to determine the labels for.
|
||||
*
|
||||
* @return array The labels.
|
||||
*/
|
||||
protected function get_labels( Indexable $indexable ) {
|
||||
$labels = [];
|
||||
if ( $indexable->is_cornerstone ) {
|
||||
$labels[] = 'cornerstone';
|
||||
}
|
||||
|
||||
$labels[] = $this->get_sub_type_singular_label( $indexable->object_type, $indexable->object_sub_type );
|
||||
|
||||
return $labels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts the given link suggestion by field.
|
||||
*
|
||||
* @param array $link_suggestions The link suggestions to sort.
|
||||
* @param string $field The field to sort suggestions by.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function sort_suggestions_by_field( array &$link_suggestions, $field ) {
|
||||
\usort(
|
||||
$link_suggestions,
|
||||
static function ( $suggestion_1, $suggestion_2 ) use ( $field ) {
|
||||
if ( $suggestion_1[ $field ] === $suggestion_2[ $field ] ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return ( ( $suggestion_1[ $field ] < $suggestion_2[ $field ] ) ? 1 : -1 );
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the suggestions by cornerstone status.
|
||||
*
|
||||
* @param array $link_suggestions The suggestions to filter.
|
||||
* @param bool $cornerstone Whether or not to include the cornerstone suggestions.
|
||||
*
|
||||
* @return array The filtered suggestions.
|
||||
*/
|
||||
protected function filter_suggestions( $link_suggestions, $cornerstone ) {
|
||||
return \array_filter(
|
||||
$link_suggestions,
|
||||
static function ( $suggestion ) use ( $cornerstone ) {
|
||||
return (bool) $suggestion['isCornerstone'] === $cornerstone;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Yoast\WP\SEO\Premium\Actions\Prominent_Words;
|
||||
|
||||
use Yoast\WP\SEO\Premium\Helpers\Prominent_Words_Helper;
|
||||
|
||||
/**
|
||||
* Action for completing the prominent words indexing.
|
||||
*/
|
||||
class Complete_Action {
|
||||
|
||||
/**
|
||||
* Represents the prominent words helper.
|
||||
*
|
||||
* @var Prominent_Words_Helper
|
||||
*/
|
||||
protected $prominent_words_helper;
|
||||
|
||||
/**
|
||||
* Complete_Action constructor.
|
||||
*
|
||||
* @param Prominent_Words_Helper $prominent_words_helper The prominent words helper.
|
||||
*/
|
||||
public function __construct( Prominent_Words_Helper $prominent_words_helper ) {
|
||||
$this->prominent_words_helper = $prominent_words_helper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the indexing state to complete.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function complete() {
|
||||
$this->prominent_words_helper->complete_indexing();
|
||||
}
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Yoast\WP\SEO\Premium\Actions\Prominent_Words;
|
||||
|
||||
use WPSEO_Premium_Prominent_Words_Support;
|
||||
use WPSEO_Premium_Prominent_Words_Versioning;
|
||||
use Yoast\WP\Lib\ORM;
|
||||
use Yoast\WP\SEO\Actions\Indexing\Indexation_Action_Interface;
|
||||
use Yoast\WP\SEO\Context\Meta_Tags_Context;
|
||||
use Yoast\WP\SEO\Helpers\Meta_Helper;
|
||||
use Yoast\WP\SEO\Memoizers\Meta_Tags_Context_Memoizer;
|
||||
use Yoast\WP\SEO\Models\Indexable;
|
||||
use Yoast\WP\SEO\Repositories\Indexable_Repository;
|
||||
|
||||
/**
|
||||
* Retrieves the indexable data and Yoast SEO metadata (meta-description, SEO title, keywords and synonyms)
|
||||
* from the database.
|
||||
*/
|
||||
class Content_Action implements Indexation_Action_Interface {
|
||||
|
||||
public const TRANSIENT_CACHE_KEY = 'total_unindexed_prominent_words';
|
||||
|
||||
/**
|
||||
* An instance of the WPSEO_Premium_Prominent_Words_Support.
|
||||
*
|
||||
* @var WPSEO_Premium_Prominent_Words_Support An instance of the WPSEO_Premium_Prominent_Words_Support
|
||||
*/
|
||||
protected $prominent_words_support;
|
||||
|
||||
/**
|
||||
* Reference to the indexable repository to retrieve outdated indexables.
|
||||
*
|
||||
* @var Indexable_Repository
|
||||
*/
|
||||
protected $indexable_repository;
|
||||
|
||||
/**
|
||||
* The meta tags context memoizer.
|
||||
*
|
||||
* @var Meta_Tags_Context_Memoizer
|
||||
*/
|
||||
protected $memoizer;
|
||||
|
||||
/**
|
||||
* The meta value helper.
|
||||
*
|
||||
* @var Meta_Helper
|
||||
*/
|
||||
protected $meta;
|
||||
|
||||
/**
|
||||
* Holds the object sub types.
|
||||
*
|
||||
* @var array|null
|
||||
*/
|
||||
protected $object_sub_types;
|
||||
|
||||
/**
|
||||
* Content_Action constructor.
|
||||
*
|
||||
* @param WPSEO_Premium_Prominent_Words_Support $prominent_words_support An instance of
|
||||
* WPSEO_Premium_Prominent_Words_Support.
|
||||
* @param Indexable_Repository $indexable_repository An instance of Indexable_Repository.
|
||||
* @param Meta_Tags_Context_Memoizer $memoizer The meta tags context memoizer.
|
||||
* @param Meta_Helper $meta The meta value helper.
|
||||
*/
|
||||
public function __construct(
|
||||
WPSEO_Premium_Prominent_Words_Support $prominent_words_support,
|
||||
Indexable_Repository $indexable_repository,
|
||||
Meta_Tags_Context_Memoizer $memoizer,
|
||||
Meta_Helper $meta
|
||||
) {
|
||||
$this->prominent_words_support = $prominent_words_support;
|
||||
$this->indexable_repository = $indexable_repository;
|
||||
$this->memoizer = $memoizer;
|
||||
$this->meta = $meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of indexables to be indexed for internal linking suggestions in one batch.
|
||||
*
|
||||
* @return int The number of indexables to be indexed in one batch.
|
||||
*/
|
||||
public function get_limit() {
|
||||
/**
|
||||
* Filter 'wpseo_prominent_words_indexation_limit' - Allow filtering the amount of indexables indexed during each indexing pass.
|
||||
*
|
||||
* @param int $max The maximum number of indexables indexed.
|
||||
*/
|
||||
$limit = \apply_filters( 'wpseo_prominent_words_indexation_limit', 25 );
|
||||
|
||||
if ( ! \is_int( $limit ) || $limit < 1 ) {
|
||||
$limit = 25;
|
||||
}
|
||||
|
||||
return $limit;
|
||||
}
|
||||
|
||||
/**
|
||||
* The total number of indexables without prominent words.
|
||||
*
|
||||
* @return int|false The total number of indexables without prominent words. False if the query fails.
|
||||
*/
|
||||
public function get_total_unindexed() {
|
||||
$object_sub_types = $this->get_object_sub_types();
|
||||
if ( empty( $object_sub_types ) ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// This prevents an expensive query.
|
||||
$total_unindexed = \get_transient( static::TRANSIENT_CACHE_KEY );
|
||||
if ( $total_unindexed !== false ) {
|
||||
return (int) $total_unindexed;
|
||||
}
|
||||
|
||||
// Try a less expensive query first: check if the indexable table holds any indexables.
|
||||
// If not, no need to perform a query on the prominent words version and more.
|
||||
if ( ! $this->at_least_one_indexable() ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Run the expensive query to find out the exact number and store it for later use.
|
||||
$total_unindexed = $this->query()->count();
|
||||
\set_transient( static::TRANSIENT_CACHE_KEY, $total_unindexed, \DAY_IN_SECONDS );
|
||||
|
||||
return $total_unindexed;
|
||||
}
|
||||
|
||||
/**
|
||||
* The total number of indexables without prominent words.
|
||||
*
|
||||
* @param int $limit Limit the number of unindexed objects that are counted.
|
||||
*
|
||||
* @return int|false The total number of indexables without prominent words. False if the query fails.
|
||||
*/
|
||||
public function get_limited_unindexed_count( $limit ) {
|
||||
return $this->get_total_unindexed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a batch of indexables, to be indexed for internal linking suggestions.
|
||||
*
|
||||
* @return array The indexables data to use for generating prominent words.
|
||||
*/
|
||||
public function index() {
|
||||
$object_sub_types = $this->get_object_sub_types();
|
||||
if ( empty( $object_sub_types ) ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$indexables = $this
|
||||
->query()
|
||||
->limit( $this->get_limit() )
|
||||
->find_many();
|
||||
|
||||
if ( \count( $indexables ) > 0 ) {
|
||||
\delete_transient( static::TRANSIENT_CACHE_KEY );
|
||||
}
|
||||
|
||||
// If no indexables have been left unindexed, return the empty array.
|
||||
if ( ! $indexables ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->format_data( $indexables );
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a query that can find indexables with outdated prominent words.
|
||||
*
|
||||
* @return ORM Returns an ORM instance that can be used to execute the query.
|
||||
*/
|
||||
protected function query() {
|
||||
$updated_version = WPSEO_Premium_Prominent_Words_Versioning::get_version_number();
|
||||
|
||||
return $this->indexable_repository
|
||||
->query()
|
||||
->where_in( 'object_type', [ 'post', 'term' ] )
|
||||
->where_in( 'object_sub_type', $this->get_object_sub_types() )
|
||||
->where_raw( '(`prominent_words_version` IS NULL OR `prominent_words_version` != ' . $updated_version . ')' )
|
||||
->where_raw( '((`post_status` IS NULL AND `object_type` = \'term\') OR (`post_status` = \'publish\' AND `object_type` = \'post\'))' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a query that checks whether the indexable table holds at least one record.
|
||||
*
|
||||
* @return bool true if at the database contains at least one indexable.
|
||||
*/
|
||||
protected function at_least_one_indexable() {
|
||||
return $this->indexable_repository
|
||||
->query()
|
||||
->select( 'id' )
|
||||
->find_one() !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a list of subtypes to get indexables for.
|
||||
*
|
||||
* @return array The array with object subtypes.
|
||||
*/
|
||||
protected function get_object_sub_types() {
|
||||
if ( $this->object_sub_types === null ) {
|
||||
$this->object_sub_types = \array_merge(
|
||||
$this->prominent_words_support->get_supported_post_types(),
|
||||
$this->prominent_words_support->get_supported_taxonomies()
|
||||
);
|
||||
}
|
||||
|
||||
return $this->object_sub_types;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the data of the given array of indexables, so it can be used to generate prominent words.
|
||||
*
|
||||
* @param Indexable[] $indexables The indexables to gather data for.
|
||||
*
|
||||
* @return array The data.
|
||||
*/
|
||||
protected function format_data( $indexables ) {
|
||||
$data = [];
|
||||
foreach ( $indexables as $indexable ) {
|
||||
// Use the meta context, so we are sure that the data is the same as is output on the frontend.
|
||||
$context = $this->get_context( $indexable );
|
||||
|
||||
if ( ! $context ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$data[] = [
|
||||
'object_id' => $indexable->object_id,
|
||||
'object_type' => $indexable->object_type,
|
||||
'content' => $this->get_content( $context ),
|
||||
'meta' => [
|
||||
'primary_focus_keyword' => $context->indexable->primary_focus_keyword,
|
||||
'title' => $context->title,
|
||||
'description' => $context->description,
|
||||
'keyphrase_synonyms' => $this->retrieve_keyphrase_synonyms( $context->indexable ),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the context for the current indexable.
|
||||
*
|
||||
* @param Indexable $indexable The indexable to get context for.
|
||||
*
|
||||
* @return Meta_Tags_Context|null The context object.
|
||||
*/
|
||||
protected function get_context( $indexable ) {
|
||||
if ( $indexable->object_type === 'post' ) {
|
||||
return $this->memoizer->get( $indexable, 'Post_Type' );
|
||||
}
|
||||
|
||||
if ( $indexable->object_type === 'term' ) {
|
||||
return $this->memoizer->get( $indexable, 'Term_Archive' );
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the keyphrase synonyms for the indexable.
|
||||
*
|
||||
* @param Indexable $indexable The indexable to retrieve synonyms for.
|
||||
*
|
||||
* @return string[] The keyphrase synonyms.
|
||||
*/
|
||||
protected function retrieve_keyphrase_synonyms( $indexable ) {
|
||||
if ( $indexable->object_type === 'post' ) {
|
||||
return \json_decode( $this->meta->get_value( 'keywordsynonyms', $indexable->object_id ) );
|
||||
}
|
||||
|
||||
if ( $indexable->object_type === 'term' ) {
|
||||
return \json_decode( $this->meta->get_term_value( $indexable->object_id, $indexable->object_sub_type, 'wpseo_keywordsynonyms' ) );
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the content to use.
|
||||
*
|
||||
* @param Meta_Tags_Context $context The meta tags context object.
|
||||
*
|
||||
* @return string The content associated with the given context.
|
||||
*/
|
||||
protected function get_content( Meta_Tags_Context $context ) {
|
||||
if ( $context->indexable->object_type === 'post' ) {
|
||||
global $post;
|
||||
|
||||
/*
|
||||
* Set the global $post to be the post in this iteration.
|
||||
* This is required for post-specific shortcodes that reference the global post.
|
||||
*/
|
||||
|
||||
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- To setup the post we need to do this explicitly.
|
||||
$post = $context->post;
|
||||
|
||||
// Set up WordPress data for this post, outside of "the_loop".
|
||||
\setup_postdata( $post );
|
||||
|
||||
// Wraps in output buffering to prevent shortcodes that echo stuff instead of return from breaking things.
|
||||
\ob_start();
|
||||
$content = \do_shortcode( $post->post_content );
|
||||
\ob_end_clean();
|
||||
|
||||
\wp_reset_postdata();
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
if ( $context->indexable->object_type === 'term' ) {
|
||||
$term = \get_term( $context->indexable->object_id, $context->indexable->object_sub_type );
|
||||
if ( $term === null || \is_wp_error( $term ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Wraps in output buffering to prevent shortcodes that echo stuff instead of return from breaking things.
|
||||
\ob_start();
|
||||
$description = \do_shortcode( $term->description );
|
||||
\ob_end_clean();
|
||||
|
||||
return $description;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Yoast\WP\SEO\Premium\Actions\Prominent_Words;
|
||||
|
||||
use Exception;
|
||||
use WPSEO_Premium_Prominent_Words_Versioning;
|
||||
use Yoast\WP\SEO\Models\Prominent_Words;
|
||||
use Yoast\WP\SEO\Premium\Helpers\Prominent_Words_Helper;
|
||||
use Yoast\WP\SEO\Premium\Repositories\Prominent_Words_Repository;
|
||||
use Yoast\WP\SEO\Repositories\Indexable_Repository;
|
||||
|
||||
/**
|
||||
* Action for updating the prominent words in the prominent words table,
|
||||
* and linking them to an indexable.
|
||||
*
|
||||
* @see \Yoast\WP\SEO\Premium\Routes\Prominent_Words_Route;
|
||||
*/
|
||||
class Save_Action {
|
||||
|
||||
/**
|
||||
* The repository to retrieve and save prominent words with.
|
||||
*
|
||||
* @var Prominent_Words_Repository
|
||||
*/
|
||||
protected $prominent_words_repository;
|
||||
|
||||
/**
|
||||
* The repository to retrieve and save indexables with.
|
||||
*
|
||||
* @var Indexable_Repository
|
||||
*/
|
||||
protected $indexable_repository;
|
||||
|
||||
/**
|
||||
* Contains helper function for prominent words.
|
||||
* For e.g. computing vector lengths and tf-idf scores.
|
||||
*
|
||||
* @var Prominent_Words_Helper
|
||||
*/
|
||||
protected $prominent_words_helper;
|
||||
|
||||
/**
|
||||
* Prominent_Words_Link_Service constructor.
|
||||
*
|
||||
* @param Prominent_Words_Repository $prominent_words_repository The repository to create, read, update and delete
|
||||
* prominent words from.
|
||||
* @param Indexable_Repository $indexable_repository The repository to read, update and delete
|
||||
* indexables from.
|
||||
* @param Prominent_Words_Helper $prominent_words_helper The prominent words helper.
|
||||
*/
|
||||
public function __construct(
|
||||
Prominent_Words_Repository $prominent_words_repository,
|
||||
Indexable_Repository $indexable_repository,
|
||||
Prominent_Words_Helper $prominent_words_helper
|
||||
) {
|
||||
$this->prominent_words_repository = $prominent_words_repository;
|
||||
$this->indexable_repository = $indexable_repository;
|
||||
$this->prominent_words_helper = $prominent_words_helper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Passes to-be-linked prominent words to the link function, together with the object type and object id of the
|
||||
* indexable to which they will need to be linked.
|
||||
*
|
||||
* @param array $data The data to process. This is an array consisting of associative arrays (1 per indexable) with the keys
|
||||
* 'object_id', 'object_type' and 'prominent_words' (an array with 'stem' => 'weight' mappings).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function save( $data ) {
|
||||
if ( $data ) {
|
||||
foreach ( $data as $row ) {
|
||||
$prominent_words = ( $row['prominent_words'] ?? [] );
|
||||
|
||||
$this->link( $row['object_type'], $row['object_id'], $prominent_words );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts, updates and removes prominent words that are now, or are no longer, associated with an indexable.
|
||||
*
|
||||
* @param string $object_type The object type of the indexable (e.g. `post` or `term`).
|
||||
* @param int $object_id The object id of the indexable.
|
||||
* @param array $words The words to link, as a `'stem' => weight` map.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function link( $object_type, $object_id, $words ) {
|
||||
$indexable = $this->indexable_repository->find_by_id_and_type( $object_id, $object_type );
|
||||
|
||||
if ( $indexable ) {
|
||||
// Set the prominent words version number on the indexable.
|
||||
$indexable->prominent_words_version = WPSEO_Premium_Prominent_Words_Versioning::get_version_number();
|
||||
|
||||
/*
|
||||
* It is correct to save here, because if the indexable didn't exist yet,
|
||||
* find_by_id_and_type (in the above 'save' function) will have auto-created an indexable object
|
||||
* with the correct data. So we are not saving an incomplete indexable.
|
||||
*/
|
||||
$indexable->save();
|
||||
|
||||
// Find the prominent words that were already associated with this indexable.
|
||||
$old_words = $this->prominent_words_repository->find_by_indexable_id( $indexable->id );
|
||||
|
||||
// Handle these words.
|
||||
$words = $this->handle_old_words( $indexable->id, $old_words, $words );
|
||||
|
||||
// Create database entries for all new words that are not yet in the database.
|
||||
$this->create_words( $indexable->id, $words );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes outdated prominent words from the database, and otherwise considers
|
||||
* whether the old words need to have their weights updated.
|
||||
*
|
||||
* @param int $indexable_id The id of the indexable which needs to have its
|
||||
* old words updated.
|
||||
* @param Prominent_Words[] $old_words An array with prominent words that were already
|
||||
* present in the database for a given indexable.
|
||||
* @param array $words The new prominent words for a given indexable.
|
||||
*
|
||||
* @return array The words that need to be created.
|
||||
*/
|
||||
protected function handle_old_words( $indexable_id, $old_words, $words ) {
|
||||
// Return early if the indexable didn't already have any prominent words associated with it.
|
||||
if ( empty( $old_words ) ) {
|
||||
return $words;
|
||||
}
|
||||
|
||||
$outdated_stems = [];
|
||||
|
||||
foreach ( $old_words as $old_word ) {
|
||||
// If an old prominent word is no longer associated with an indexable,
|
||||
// add it to the array with outdated stems, so that at a later step
|
||||
// it can be deleted from the database.
|
||||
if ( ! \array_key_exists( $old_word->stem, $words ) ) {
|
||||
$outdated_stems[] = $old_word->stem;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// If the old word should still be associated with the indexable,
|
||||
// update its weight if that has changed.
|
||||
$this->update_weight_if_changed( $old_word, $words[ $old_word->stem ] );
|
||||
|
||||
// Remove the key from the array with the new prominent words.
|
||||
unset( $words[ $old_word->stem ] );
|
||||
}
|
||||
|
||||
// Delete all the outdated prominent words in one query.
|
||||
try {
|
||||
$this->prominent_words_repository->delete_by_indexable_id_and_stems( $indexable_id, $outdated_stems );
|
||||
// phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- There is nothing to do.
|
||||
} catch ( Exception $exception ) {
|
||||
// Do nothing.
|
||||
}
|
||||
|
||||
return $words;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the weight of the given prominent word, if the weight has changed significantly.
|
||||
*
|
||||
* @param Prominent_Words $word The prominent word of which to update the weight.
|
||||
* @param float $new_weight The new weight.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function update_weight_if_changed( $word, $new_weight ) {
|
||||
if ( \abs( $word->weight - $new_weight ) > 0.1 ) {
|
||||
$word->weight = $new_weight;
|
||||
$word->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the given words in the database and links them to the indexable with the given id.
|
||||
*
|
||||
* @param int $indexable_id The ID of the indexable.
|
||||
* @param array $words The prominent words to create, as a `'stem'` => weight` map.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function create_words( $indexable_id, $words ) {
|
||||
// Return early if there are no new words to add to the database.
|
||||
if ( empty( $words ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$new_models = [];
|
||||
|
||||
foreach ( $words as $stem => $weight ) {
|
||||
$new_model = $this->prominent_words_repository->query()->create(
|
||||
[
|
||||
'indexable_id' => $indexable_id,
|
||||
'stem' => $stem,
|
||||
'weight' => $weight,
|
||||
]
|
||||
);
|
||||
$new_models[] = $new_model;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->prominent_words_repository->query()->insert_many( $new_models );
|
||||
// phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- There is nothing to do.
|
||||
} catch ( Exception $exception ) {
|
||||
// Do nothing.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user