File "post-analyzer.php"
Full Path: /home/fresvfqn/waterdamagerestorationandrepairsmithtown.com/wp-content/plugins/surerank/inc/ajax/post-analyzer.php
File size: 15.97 KB
MIME-type: text/x-php
Charset: utf-8
<?php
/**
* Post Analyzer class.
*
* Performs SEO checks for WordPress posts like page, post, cpts with consistent output for UI.
*
* @package SureRank\Inc\Analyzer
*/
namespace SureRank\Inc\Analyzer;
use DOMElement;
use DOMNodeList;
use DOMXPath;
use SureRank\Inc\API\Admin;
use SureRank\Inc\API\Post;
use SureRank\Inc\Frontend\Image_Seo;
use SureRank\Inc\Functions\Get;
use SureRank\Inc\Functions\Helper;
use SureRank\Inc\Functions\Settings;
use SureRank\Inc\Functions\Update;
use SureRank\Inc\Traits\Get_Instance;
use SureRank\Inc\Traits\Logger;
use WP_Post;
/**
* Post Analyzer
*/
class PostAnalyzer {
use Get_Instance;
use Logger;
/**
* XPath instance.
*
* @var DOMXPath|null
*/
private $xpath;
/**
* Page title.
*
* @var string|null
*/
private $page_title;
/**
* Page description.
*
* @var string|null
*/
private $page_description = '';
/**
* Canonical URL.
*
* @var string|null
*/
private $canonical_url = '';
/**
* Post ID.
*
* @var int|null
*/
private $post_id;
/**
* Post permalink.
*
* @var string
*/
private $post_permalink = '';
/**
* Post content.
*
* @var string
*/
private $post_content = '';
/**
* Constructor.
*/
private function __construct() {
if ( ! Settings::get( 'enable_page_level_seo' ) ) {
return;
}
add_action( 'wp_after_insert_post', [ $this, 'save_post' ], 10, 2 );
add_filter( 'surerank_run_post_seo_checks', [ $this, 'run_checks' ], 10, 2 );
}
/**
* Handle post save to run SEO checks.
*
* @param int $post_id Post ID.
* @param WP_Post $post Post object.
* @return void
*/
public function save_post( $post_id, $post ) {
if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
return;
}
$post_type = get_post_type( $post_id );
if ( ! $post_type ) {
return;
}
$post_type_object = get_post_type_object( $post_type );
if ( ! $post_type_object || ! $post_type_object->public ||
in_array( $post_type, apply_filters( 'surerank_excluded_post_types_from_seo_checks', [] ), true ) ) {
return;
}
$response = $this->run_checks( $post_id, $post );
if ( isset( $response['status'] ) && 'error' === $response['status'] ) {
self::log( $response['message'] );
}
}
/**
* Run SEO checks for the post.
*
* @param int $post_id Post ID.
* @param WP_Post $post Post object.
* @return array<string, mixed>
*/
public function run_checks( $post_id, $post ) {
$this->post_id = $post_id;
if ( ! $this->post_id || ! $post instanceof WP_Post ) {
return [
'status' => 'error',
];
}
$meta_data = Post::get_post_data_by_id( $post_id, $post->post_type, false );
$variables = Admin::get_instance()->get_variables( $post_id, null );
$meta_data = Utils::get_meta_data( $meta_data );
foreach ( $meta_data as $key => $value ) {
$meta_data[ $key ] = Helper::replacement( $key, $value, $variables );
}
$this->page_title = $meta_data['page_title'] ?? '';
$this->page_description = $meta_data['page_description'] ?? '';
$this->canonical_url = $meta_data['canonical_url'] ?? '';
$this->post_permalink = $this->get_original_permalink( $post_id, $post );
$this->post_content = $post->post_content;
/**
* Parse blocks and render them to get the rendered content.
* Commented out because it's not needed for the analyzer.
*
* Kept here for reference if needed in the future.
*
* $blocks = parse_blocks( $post_content );
* foreach ( $blocks as $block ) {
* $rendered_content .= render_block( $block );
* }
*/
$this->xpath = Utils::get_rendered_xpath( $this->post_content );
$result = $this->analyze( $meta_data );
if ( $this->update_broken_links_status( $result ) && is_array( $result ) ) {
$result['broken_links'] = $this->update_broken_links_status( $result );
}
$success = Update::post_seo_checks( $post_id, $result );
if ( ! $success ) {
return [
'status' => 'error',
'message' => __( 'Failed to update SEO checks', 'surerank' ),
];
}
return $result;
}
/**
* Analyze the post.
*
* @param array<string, mixed> $meta_data Meta data.
* @return array<string, mixed>
*/
private function analyze( array $meta_data ) {
// Get focus keyword for keyword checks.
$focus_keyword = $meta_data['focus_keyword'] ?? '';
return [
'h2_subheadings' => $this->check_subheadings(),
'image_alt_text' => $this->check_image_alt_text(),
'media_present' => $this->check_media_present(),
'links_present' => $this->check_links_present(),
'url_length' => Utils::check_url_length( $this->post_permalink ),
'search_engine_title' => Utils::analyze_title( $this->page_title ),
'search_engine_description' => Utils::analyze_description( $this->page_description ),
'canonical_url' => $this->canonical_url(),
'all_links' => $this->get_all_links(),
'open_graph_tags' => Utils::open_graph_tags(),
// Keyword checks.
'keyword_in_title' => Utils::analyze_keyword_in_title( $this->page_title, $focus_keyword ),
'keyword_in_description' => Utils::analyze_keyword_in_description( $this->page_description, $focus_keyword ),
'keyword_in_url' => Utils::analyze_keyword_in_url( $this->post_permalink, $focus_keyword ),
'keyword_in_content' => Utils::analyze_keyword_in_content( $this->post_content, $focus_keyword ),
];
}
/**
* Check for H2 subheadings.
*
* @return array<string, mixed>
*/
private function check_subheadings() {
$headings = [ 'h2', 'h3', 'h4', 'h5', 'h6' ];
$count = 0;
foreach ( $headings as $tag ) {
$elements = $this->xpath ? $this->xpath->query( "//{$tag}" ) : null;
$count += $elements instanceof DOMNodeList ? $elements->length : 0;
}
if ( $count === 0 ) {
return [
'status' => 'warning',
'message' => __( 'The page does not contain any subheadings.', 'surerank' ),
];
}
return [
'status' => 'success',
'message' => __( 'Page contains at least one subheading.', 'surerank' ),
];
}
/**
* Check for image alt text.
*
* @return array<string, mixed>
*/
private function check_image_alt_text(): array {
$images = $this->get_images();
if ( ! $images instanceof DOMNodeList || $images->length === 0 ) {
return [];
}
$analysis = $this->analyze_images( $images );
return $this->build_image_alt_response( $analysis );
}
/**
* Get all images from the content.
*
* @return \DOMNodeList<\DOMNode>|null List of image elements.
*/
private function get_images(): ?\DOMNodeList {
$result = $this->xpath ? $this->xpath->query( '//img' ) : null;
return $result === false ? null : $result;
}
/**
* Analyze images for alt text attributes.
*
* @param \DOMNodeList<\DOMNode> $images List of image elements.
* @return array{total: int, missing_alt: int, missing_alt_images: array<string>} Analysis results.
*/
private function analyze_images( $images ): array {
$analysis = [
'total' => $images->length,
'missing_alt' => 0,
'missing_alt_images' => [],
];
foreach ( $images as $img ) {
if ( $this->is_missing_alt_text( $img ) ) {
$analysis['missing_alt']++;
$src = $this->get_image_src( $img );
if ( $src ) {
$analysis['missing_alt_images'][] = $src;
}
}
}
return $analysis;
}
/**
* Check if an image element is missing alt text.
*
* @param \DOMNode $img Image element.
* @return bool True if missing alt text.
*/
private function is_missing_alt_text( \DOMNode $img ): bool {
if ( ! $img instanceof \DOMElement ) {
return false;
}
return ! $img->hasAttribute( 'alt' ) || empty( trim( $img->getAttribute( 'alt' ) ) );
}
/**
* Get the src attribute from an image element.
*
* @param \DOMNode $img Image element.
* @return string Image source URL or empty string.
*/
private function get_image_src( \DOMNode $img ): string {
if ( ! $img instanceof \DOMElement ) {
return '';
}
return $img->hasAttribute( 'src' ) ? $img->getAttribute( 'src' ) : '';
}
/**
* Build the response array for image alt text analysis.
*
* @param array{total: int, missing_alt: int, missing_alt_images: array<string>} $analysis Analysis results.
* @return array<string, mixed> Response array.
*/
private function build_image_alt_response( array $analysis ): array {
$exists = $analysis['total'] > 0;
$is_optimized = $exists && $analysis['missing_alt'] === 0;
$status = $this->get_alt_text_status( $exists, $is_optimized );
$message = $this->get_alt_text_message( $exists, $is_optimized );
return [
'status' => $status,
'description' => $this->build_image_description( $exists, $analysis['total'], $analysis['missing_alt'], $analysis['missing_alt_images'] ),
'message' => $message,
'show_images' => $exists && $analysis['missing_alt'] > 0,
];
}
/**
* Get the status for alt text analysis.
*
* @param bool $exists Whether images exist.
* @param bool $is_optimized Whether all images have alt text.
* @return string Status string.
*/
private function get_alt_text_status( bool $exists, bool $is_optimized ): string {
if ( ! $exists ) {
return 'warning';
}
if ( Image_Seo::get_instance()->status() ) {
return 'suggestion';
}
return $is_optimized ? 'success' : 'warning';
}
/**
* Get the message for alt text analysis.
*
* @param bool $exists Whether images exist.
* @param bool $is_optimized Whether all images have alt text.
* @return string Message string.
*/
private function get_alt_text_message( bool $exists, bool $is_optimized ): string {
if ( $exists && $is_optimized ) {
return __( 'All images on this page have alt text attributes.', 'surerank' );
}
$base_message = __( 'One or more images are missing alt text attributes.', 'surerank' );
if ( Image_Seo::get_instance()->status() ) {
return $base_message . ' ' . __( 'But don\'t worry, we will add them automatically for you.', 'surerank' );
}
return $base_message . ' ' . __( 'You can add them manually or turn on auto-set image title and alt in the settings.', 'surerank' );
}
/**
* Build image description.
*
* @param bool $exists Whether images exist.
* @param int $total Total number of images.
* @param int $missing_alt Number of images missing alt text.
* @param array<string> $missing_alt_images Images missing alt text.
* @return array<int, array<string, array<int, string>>|string>
*/
private function build_image_description( bool $exists, int $total, int $missing_alt, array $missing_alt_images ): array {
if ( ! $exists ) {
return $this->get_no_images_description();
}
if ( $missing_alt === 0 ) {
return $this->get_optimized_images_description();
}
return $this->get_missing_alt_description( $missing_alt_images );
}
/**
* Get description for pages with no images.
*
* @return array<int, string> Description array.
*/
private function get_no_images_description(): array {
return [
__( 'The page does not contain any images.', 'surerank' ),
__( 'Add images to improve the post/page\'s visual appeal and SEO.', 'surerank' ),
];
}
/**
* Get description for pages with optimized images.
*
* @return array<int, string> Description array.
*/
private function get_optimized_images_description(): array {
return [
__( 'Images on the post/page have alt text attributes', 'surerank' ),
];
}
/**
* Get description for pages with missing alt text.
*
* @param array<string> $missing_alt_images Images missing alt text.
* @return array<int, array<string, array<int, string>>> Description array.
*/
private function get_missing_alt_description( array $missing_alt_images ): array {
if ( empty( $missing_alt_images ) ) {
return [];
}
$list = [];
foreach ( array_unique( $missing_alt_images ) as $image ) {
$list[] = esc_html( $image );
}
return [
[ 'list' => $list ],
];
}
/**
* Check for media present.
*
* @return array<string, mixed>
*/
private function check_media_present() {
$images = $this->xpath ? $this->xpath->query( '//img' ) : new DOMNodeList();
$videos = $this->xpath ? $this->xpath->query( '//video' ) : new DOMNodeList();
$featured_image = get_post_thumbnail_id( $this->post_id );
$image_length = $images->length ?? 0;
$video_length = $videos->length ?? 0;
$exists = $image_length > 0 || $video_length > 0 || $featured_image;
$message = $exists ? __( 'This page includes images or videos to enhance content.', 'surerank' ) : __( 'No images or videos found on this page.', 'surerank' );
return [
'status' => $exists ? 'success' : 'warning',
'message' => $message,
];
}
/**
* Check for links present.
*
* @return array<string, mixed>
*/
private function check_links_present() {
$links = $this->xpath ? $this->xpath->query( '//a[@href]' ) : new DOMNodeList();
if ( ! $links || $links->length === 0 ) {
return [
'status' => 'warning',
'message' => __( 'No links found on the page.', 'surerank' ),
];
}
return [
'status' => 'success',
'message' => __( 'Links are present on the page.', 'surerank' ),
];
}
/**
* Get canonical URL.
*
* @return array<string, mixed>
*/
private function canonical_url() {
if ( $this->canonical_url === null ) {
return [
'status' => 'error',
'message' => __( 'No canonical URL provided.', 'surerank' ),
];
}
$permalink = get_permalink( (int) $this->post_id );
if ( ! $permalink ) {
return [
'status' => 'error',
'message' => __( 'No permalink provided.', 'surerank' ),
];
}
return Utils::analyze_canonical_url( $this->canonical_url, $permalink );
}
/**
* Update broken links status.
*
* @param array<string, mixed> $result Result.
* @return array<string, mixed>|false
*/
private function update_broken_links_status( $result ) {
$links = $this->xpath ? $this->xpath->query( '//a[@href]' ) : new DOMNodeList();
$empty_message = [
'status' => 'success',
'message' => __( 'No broken links found on the page.', 'surerank' ),
];
if ( ! $links || $links->length === 0 ) {
return $empty_message;
}
$urls = [];
foreach ( $links as $link ) {
if ( $link instanceof DOMElement ) {
if ( ! in_array( $link->getAttribute( 'href' ), $urls ) ) {
$urls[] = $link->getAttribute( 'href' );
}
}
}
$broken_links = Get::post_meta( (int) $this->post_id, SURERANK_SEO_CHECKS, true );
$broken_links = $broken_links['broken_links'] ?? [];
$existing_broken_links = Utils::existing_broken_links( $broken_links, $urls );
if ( empty( $existing_broken_links ) ) {
return $empty_message;
}
return false;
}
/**
* Get all links from the rendered post content.
*
* @return array<string>
*/
private function get_all_links() {
if ( ! $this->xpath ) {
return [];
}
$links = [];
$anchor_nodes = $this->xpath->query( '//a[@href]' );
if ( ! $anchor_nodes instanceof DOMNodeList ) {
return [];
}
foreach ( $anchor_nodes as $anchor ) {
if ( $anchor instanceof DOMElement ) {
$href = trim( $anchor->getAttribute( 'href' ) );
if ( $href !== '' && ! in_array( $href, $links, true ) ) {
$links[] = $href;
}
}
}
return $links;
}
/**
* Get the original permalink for a post, even if it's set as homepage.
*
* @param int $post_id Post ID.
* @param WP_Post $post Post object.
* @return string Original permalink or empty string.
*/
private function get_original_permalink( $post_id, $post ) {
$homepage_id = (int) get_option( 'page_on_front' );
if ( $homepage_id === $post_id ) {
return $this->generate_original_page_url( $post );
}
$permalink = get_permalink( $post_id );
return $permalink !== false ? $permalink : '';
}
/**
* Generate original page URL for a post that's set as homepage.
*
* @param WP_Post $post Post object.
* @return string Original page URL.
*/
private function generate_original_page_url( $post ) {
if ( empty( $post->post_name ) ) {
return '';
}
return trailingslashit( home_url() ) . $post->post_name . '/';
}
}