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 . '/';
	}

}