547 lines
16 KiB
PHP
547 lines
16 KiB
PHP
<?php
|
|
|
|
namespace AIOSEO\Plugin\Pro\SeoAnalysis\Checkers\Post;
|
|
|
|
// Exit if accessed directly.
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
use AIOSEO\Plugin\Pro\SeoAnalysis\PageParser;
|
|
use AIOSEO\Plugin\Common\Models\Post;
|
|
use AIOSEO\Plugin\Pro\SeoAnalysis\Checkers\CheckersAbstract;
|
|
use AIOSEO\Plugin\Pro\SeoAnalysis\Checkers\CheckerResult;
|
|
|
|
/**
|
|
* Handles the SEO validation for Basic issues.
|
|
*
|
|
* @since 4.8.6
|
|
*/
|
|
class Basic extends CheckersAbstract {
|
|
/**
|
|
* Class constructor
|
|
*
|
|
* @since 4.8.6
|
|
*
|
|
* @param int $objectId The object ID.
|
|
* @param PageParser $parser The page parser instance.
|
|
*/
|
|
public function __construct( $objectId, $parser ) {
|
|
parent::__construct([
|
|
'id' => $objectId,
|
|
'type' => 'post',
|
|
'subtype' => get_post_type( $objectId ),
|
|
], $parser);
|
|
}
|
|
|
|
/**
|
|
* Analyze the page for Basic SEO issues.
|
|
*
|
|
* @since 4.8.6
|
|
*
|
|
* @return array List of checkers/issues.
|
|
*/
|
|
public function get() {
|
|
$result = [
|
|
'title' => $this->analyzeTitle(),
|
|
'description' => $this->analyzeDescription(),
|
|
'h1Tags' => $this->analyzeH1Tags(), // only for published posts
|
|
'subheadingTags' => $this->analyzeSubheadingTags(),
|
|
'images' => $this->analyzeImgs(),
|
|
'links' => $this->analyzeLinksRatio(),
|
|
'hasThumbnail' => $this->analyzeThumbnail(),
|
|
'contentLength' => $this->analyzeContentLength(),
|
|
'keywordCannibalization' => $this->analyzeHasKeywordCannibalization(), // only if is not a woocommerce page, not the posts page and is TruSEO eligible
|
|
'focusKeywordInFirstParagraph' => $this->analyzeFocusKeywordInFirstParagraph(), // only if is not a woocommerce page, not the posts page and is TruSEO eligible
|
|
'focusKeywordInTitle' => $this->analyzeFocusKeywordInTitle(), // only if is not a woocommerce page, not the posts page and is TruSEO eligible
|
|
'focusKeywordInDescription' => $this->analyzeFocusKeywordInDescription(), // only if is not a woocommerce page, not the posts page and is TruSEO eligible
|
|
'focusKeywordInUrl' => $this->analyzeFocusKeywordInUrl(), // only if is not a woocommerce page, not the posts page and is TruSEO eligible
|
|
'urlLength' => $this->analyzeUrlLength(),
|
|
'productSchema' => $this->analyzeProductSchema(), //only if is a product (WooCommerce) or download (EDD)
|
|
];
|
|
|
|
return array_filter( $result, function( $check ) {
|
|
return ! is_null( $check );
|
|
} );
|
|
}
|
|
|
|
/**
|
|
* Analyze the title tag.
|
|
*
|
|
* @since 4.8.6
|
|
*
|
|
* @return CheckerResult
|
|
*/
|
|
private function analyzeTitle() {
|
|
$titles = $this->pageParser->isScraping
|
|
? $this->pageParser->getElementsByTagName( 'title' )
|
|
: [ aioseo()->meta->title->getPostTitle( $this->object['id'] ) ];
|
|
|
|
if ( empty( $titles ) ) {
|
|
return new CheckerResult( 'title-missing' );
|
|
} elseif ( 40 >= mb_strlen( $titles[0] ) ) {
|
|
return new CheckerResult( 'title-too-short', [ 'length' => mb_strlen( $titles[0] ) ] );
|
|
} elseif ( 60 < mb_strlen( $titles[0] ) ) {
|
|
return new CheckerResult( 'title-too-long', [ 'length' => mb_strlen( $titles[0] ) ] );
|
|
}
|
|
|
|
return new CheckerResult( 'title-ok', [ 'length' => mb_strlen( $titles[0] ) ] );
|
|
}
|
|
|
|
/**
|
|
* Analyze the description tag.
|
|
*
|
|
* @since 4.8.6
|
|
*
|
|
* @return CheckerResult
|
|
*/
|
|
private function analyzeDescription() {
|
|
$descriptions = $this->pageParser->isScraping
|
|
? $this->pageParser->getMetaTag( 'description' )
|
|
: [ aioseo()->meta->description->getPostDescription( $this->object['id'] ) ];
|
|
|
|
if ( empty( $descriptions ) ) {
|
|
return new CheckerResult( 'description-missing' );
|
|
} elseif ( 120 >= mb_strlen( $descriptions[0] ) ) {
|
|
return new CheckerResult( 'description-too-short', [ 'length' => mb_strlen( $descriptions[0] ) ] );
|
|
} elseif ( 160 < mb_strlen( $descriptions[0] ) ) {
|
|
return new CheckerResult( 'description-too-long', [ 'length' => mb_strlen( $descriptions[0] ) ] );
|
|
}
|
|
|
|
return new CheckerResult( 'description-ok', [ 'length' => mb_strlen( $descriptions[0] ) ] );
|
|
}
|
|
|
|
/**
|
|
* Analyze the H1 tags.
|
|
*
|
|
* @since 4.8.6
|
|
*
|
|
* @return CheckerResult|null
|
|
*/
|
|
private function analyzeH1Tags() {
|
|
// If we're not scrapping means the post is not published yet.
|
|
if ( ! $this->pageParser->isScraping ) {
|
|
return null;
|
|
}
|
|
|
|
$tags = $this->pageParser->getElementsByTagName( 'h1' );
|
|
|
|
if ( empty( $tags ) ) {
|
|
return new CheckerResult( 'h1-missing' );
|
|
} elseif ( 1 < count( $tags ) ) {
|
|
return new CheckerResult( 'h1-too-many', [ 'length' => count( $tags ) ] );
|
|
} else {
|
|
if (
|
|
! aioseo()->helpers->isWooCommercePage( $this->object['id'] ) &&
|
|
! aioseo()->helpers->isStaticPostsPage( $this->object['id'] ) &&
|
|
aioseo()->helpers->isTruSeoEligible( $this->object['id'] )
|
|
) {
|
|
$post = Post::getPost( $this->object['id'] );
|
|
$keyphrases = Post::getKeyphrasesDefaults( $post->keyphrases );
|
|
|
|
if ( empty( $keyphrases->focus->keyphrase ) ) {
|
|
return null;
|
|
}
|
|
|
|
$text = strtolower( (string) $tags[0] );
|
|
$keyphrase = strtolower( (string) $keyphrases->focus->keyphrase );
|
|
|
|
if ( strpos( $text, $keyphrase ) === false ) {
|
|
return new CheckerResult( 'h1-missing-focus-keyword' );
|
|
}
|
|
}
|
|
}
|
|
|
|
return new CheckerResult( 'h1-ok' );
|
|
}
|
|
|
|
/**
|
|
* Analyze the subheading tags.
|
|
*
|
|
* @since 4.8.6
|
|
*
|
|
* @return CheckerResult
|
|
*/
|
|
private function analyzeSubheadingTags() {
|
|
$h2 = $this->pageParser->getElementsByTagName( 'h2' );
|
|
$h3 = $this->pageParser->getElementsByTagName( 'h3' );
|
|
$h4 = $this->pageParser->getElementsByTagName( 'h4' );
|
|
$h5 = $this->pageParser->getElementsByTagName( 'h5' );
|
|
$h6 = $this->pageParser->getElementsByTagName( 'h6' );
|
|
$tags = array_merge( $h2, $h3, $h4, $h5, $h6 );
|
|
if ( empty( $tags ) ) {
|
|
return new CheckerResult( 'subheading-missing' );
|
|
}
|
|
|
|
if (
|
|
! aioseo()->helpers->isWooCommercePage( $this->object['id'] ) &&
|
|
! aioseo()->helpers->isStaticPostsPage( $this->object['id'] ) &&
|
|
aioseo()->helpers->isTruSeoEligible( $this->object['id'] )
|
|
) {
|
|
$post = Post::getPost( $this->object['id'] );
|
|
$keyphrases = Post::getKeyphrasesDefaults( $post->keyphrases );
|
|
|
|
if ( empty( $keyphrases->focus->keyphrase ) ) {
|
|
return null;
|
|
}
|
|
|
|
$hasFocusKeyword = false;
|
|
|
|
$keyphrase = strtolower( (string) $keyphrases->focus->keyphrase );
|
|
foreach ( $tags as $tag ) {
|
|
$text = strtolower( (string) $tag );
|
|
|
|
if ( strpos( $text, $keyphrase ) !== false ) {
|
|
$hasFocusKeyword = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ( ! $hasFocusKeyword ) {
|
|
return new CheckerResult( 'subheading-missing-focus-keyword', [ 'length' => count( $tags ) ] );
|
|
}
|
|
}
|
|
|
|
return new CheckerResult( 'subheading-ok', [ 'length' => count( $tags ) ] );
|
|
}
|
|
|
|
/**
|
|
* Analyze the images considering amount and the ones without alt attributes.
|
|
*
|
|
* @since 4.8.6
|
|
*
|
|
* @return CheckerResult
|
|
*/
|
|
private function analyzeImgs() {
|
|
$imgs = $this->pageParser->getBodyImages();
|
|
|
|
if ( ! isset( $imgs['imageUrls'] ) || empty( $imgs['imageUrls'] ) ) {
|
|
return new CheckerResult( 'image-missing' );
|
|
} elseif ( isset( $imgs['imagesWithoutAltText'] ) && ! empty( $imgs['imagesWithoutAltText'] ) ) {
|
|
return new CheckerResult( 'image-missing-alt', [
|
|
'length' => count( $imgs['imagesWithoutAltText'] ),
|
|
'items' => $imgs['imagesWithoutAltText']
|
|
] );
|
|
}
|
|
|
|
return new CheckerResult( 'image-ok' );
|
|
}
|
|
|
|
/**
|
|
* Analyze the links ratio.
|
|
*
|
|
* @since 4.8.6
|
|
*
|
|
* @return CheckerResult
|
|
*/
|
|
private function analyzeLinksRatio() {
|
|
$links = $this->pageParser->getLinks();
|
|
|
|
if ( empty( $links['internalLinks'] ) ) {
|
|
return new CheckerResult( 'internal-links-missing' );
|
|
}
|
|
|
|
$content = $this->pageParser->getMain()->nodeValue ?? '';
|
|
$wordCount = str_word_count( $content );
|
|
$ratio = $wordCount > 0 ? $wordCount / 500 : 0; // 1 internal link per 500 words
|
|
|
|
$internalLinksCount = count( $links['internalLinks'] );
|
|
if ( intval( $ratio ) > $internalLinksCount ) {
|
|
return new CheckerResult( 'internal-links-too-few', [ 'length' => $internalLinksCount ] );
|
|
}
|
|
|
|
return new CheckerResult( 'links-ratio-ok' );
|
|
}
|
|
|
|
/**
|
|
* Analyze the thumbnail.
|
|
*
|
|
* @since 4.8.6
|
|
*
|
|
* @return CheckerResult
|
|
*/
|
|
private function analyzeThumbnail() {
|
|
$object = $this->object;
|
|
|
|
if ( post_type_supports( $object['subtype'], 'thumbnail' ) && ! has_post_thumbnail( $object['id'] ) ) {
|
|
return new CheckerResult( 'thumbnail-missing' );
|
|
}
|
|
|
|
return new CheckerResult( 'thumbnail-ok' );
|
|
}
|
|
|
|
/**
|
|
* Analyze the content length.
|
|
*
|
|
* @since 4.8.6
|
|
*
|
|
* @return CheckerResult
|
|
*/
|
|
private function analyzeContentLength() {
|
|
$content = $this->pageParser->getMain()->nodeValue ?? '';
|
|
if ( empty( $content ) ) {
|
|
return new CheckerResult( 'content-length-too-short', [ 'length' => 0 ] );
|
|
}
|
|
|
|
$wordCount = str_word_count( $content );
|
|
if ( 300 >= $wordCount ) {
|
|
return new CheckerResult( 'content-length-too-short', [ 'length' => $wordCount ] );
|
|
}
|
|
|
|
return new CheckerResult( 'content-length-ok' );
|
|
}
|
|
|
|
/**
|
|
* Analyze the page for Keyword Cannibalization.
|
|
*
|
|
* @since 4.8.6
|
|
*
|
|
* @return CheckerResult|null
|
|
*/
|
|
private function analyzeHasKeywordCannibalization() {
|
|
if (
|
|
aioseo()->helpers->isWooCommercePage( $this->object['id'] ) ||
|
|
aioseo()->helpers->isStaticPostsPage( $this->object['id'] ) ||
|
|
! aioseo()->helpers->isTruSeoEligible( $this->object['id'] )
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
$object = $this->object;
|
|
|
|
$post = Post::getPost( $object['id'] );
|
|
$keyphrases = Post::getKeyphrasesDefaults( $post->keyphrases );
|
|
|
|
if ( empty( $keyphrases->focus->keyphrase ) ) {
|
|
return null;
|
|
}
|
|
|
|
$posts = aioseo()->core->db->start( 'aioseo_posts as ap' )
|
|
->select( 'p.ID, p.post_title' )
|
|
->join( 'posts as p', 'p.ID = ap.post_id' )
|
|
->where( 'p.ID !=', $object['id'] )
|
|
->whereRaw( 'ap.keyphrases LIKE "%keyphrase\":\"' . esc_sql( addcslashes( $keyphrases->focus->keyphrase, '_%\\' ) ) . '\"%"' )
|
|
->run()
|
|
->result();
|
|
|
|
if ( ! empty( $posts ) ) {
|
|
return new CheckerResult( 'keyword-cannibalization', [
|
|
'posts' => array_map( function( $p ) {
|
|
return [
|
|
'title' => $p->post_title,
|
|
'editLink' => admin_url( 'post.php?post=' . $p->ID . '&action=edit' )
|
|
];
|
|
}, $posts )
|
|
] );
|
|
}
|
|
|
|
return new CheckerResult( 'keyword-cannibalization-ok' );
|
|
}
|
|
|
|
/**
|
|
* Analyze the first paragraph.
|
|
*
|
|
* @since 4.8.6
|
|
*
|
|
* @return CheckerResult|null
|
|
*/
|
|
private function analyzeFocusKeywordInFirstParagraph() {
|
|
if (
|
|
aioseo()->helpers->isWooCommercePage( $this->object['id'] ) ||
|
|
aioseo()->helpers->isStaticPostsPage( $this->object['id'] ) ||
|
|
! aioseo()->helpers->isTruSeoEligible( $this->object['id'] )
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
$tags = $this->pageParser->getSectionElements( 'main', [ 'p' ] );
|
|
if ( empty( $tags ) ) {
|
|
return null;
|
|
}
|
|
|
|
$post = Post::getPost( $this->object['id'] );
|
|
$keyphrases = Post::getKeyphrasesDefaults( $post->keyphrases );
|
|
|
|
if ( empty( $keyphrases->focus->keyphrase ) ) {
|
|
return null;
|
|
}
|
|
|
|
$text = strtolower( (string) $tags[0]->nodeValue );
|
|
$keyphrase = strtolower( (string) $keyphrases->focus->keyphrase );
|
|
|
|
if ( strpos( $text, $keyphrase ) === false ) {
|
|
return new CheckerResult( 'first-paragraph-missing-focus-keyword' );
|
|
}
|
|
|
|
return new CheckerResult( 'first-paragraph-ok' );
|
|
}
|
|
|
|
/**
|
|
* Analyze the page for Focus Keyword issues inside the Title.
|
|
*
|
|
* @since 4.8.6
|
|
*
|
|
* @return CheckerResult|null
|
|
*/
|
|
private function analyzeFocusKeywordInTitle() {
|
|
if (
|
|
aioseo()->helpers->isWooCommercePage( $this->object['id'] ) ||
|
|
aioseo()->helpers->isStaticPostsPage( $this->object['id'] ) ||
|
|
! aioseo()->helpers->isTruSeoEligible( $this->object['id'] )
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
$object = $this->object;
|
|
|
|
$post = Post::getPost( $object['id'] );
|
|
$keyphrases = Post::getKeyphrasesDefaults( $post->keyphrases );
|
|
|
|
$titles = $this->pageParser->isScraping
|
|
? $this->pageParser->getElementsByTagName( 'title' )
|
|
: [ aioseo()->meta->title->getPostTitle( $this->object['id'] ) ];
|
|
|
|
if ( empty( $keyphrases->focus->keyphrase ) || empty( $titles ) ) {
|
|
return null;
|
|
}
|
|
|
|
$text = strtolower( (string) $titles[0] );
|
|
$keyphrase = strtolower( (string) $keyphrases->focus->keyphrase );
|
|
|
|
if ( strpos( $text, $keyphrase ) === false ) {
|
|
return new CheckerResult( 'title-missing-focus-keyword' );
|
|
}
|
|
|
|
return new CheckerResult( 'title-focus-keyword-ok' );
|
|
}
|
|
|
|
/**
|
|
* Analyze the page for Focus Keyword issues inside the Description.
|
|
*
|
|
* @since 4.8.6
|
|
*
|
|
* @return CheckerResult|null
|
|
*/
|
|
private function analyzeFocusKeywordInDescription() {
|
|
if (
|
|
aioseo()->helpers->isWooCommercePage( $this->object['id'] ) ||
|
|
aioseo()->helpers->isStaticPostsPage( $this->object['id'] ) ||
|
|
! aioseo()->helpers->isTruSeoEligible( $this->object['id'] )
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
$object = $this->object;
|
|
|
|
$post = Post::getPost( $object['id'] );
|
|
$keyphrases = Post::getKeyphrasesDefaults( $post->keyphrases );
|
|
|
|
$descriptions = $this->pageParser->isScraping
|
|
? $this->pageParser->getMetaTag( 'description' )
|
|
: [ aioseo()->meta->description->getPostDescription( $this->object['id'] ) ];
|
|
|
|
if ( empty( $keyphrases->focus->keyphrase ) || empty( $descriptions ) ) {
|
|
return null;
|
|
}
|
|
|
|
$text = strtolower( (string) $descriptions[0] );
|
|
$keyphrase = strtolower( (string) $keyphrases->focus->keyphrase );
|
|
|
|
if ( strpos( $text, $keyphrase ) === false ) {
|
|
return new CheckerResult( 'description-missing-focus-keyword' );
|
|
}
|
|
|
|
return new CheckerResult( 'description-focus-keyword-ok' );
|
|
}
|
|
|
|
/**
|
|
* Analyze the page for Focus Keyword issues inside the URL slug.
|
|
*
|
|
* @since 4.8.6
|
|
*
|
|
* @return CheckerResult|null
|
|
*/
|
|
private function analyzeFocusKeywordInUrl() {
|
|
if (
|
|
aioseo()->helpers->isWooCommercePage( $this->object['id'] ) ||
|
|
aioseo()->helpers->isStaticPostsPage( $this->object['id'] ) ||
|
|
! aioseo()->helpers->isTruSeoEligible( $this->object['id'] )
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
$object = $this->object;
|
|
|
|
$post = Post::getPost( $object['id'] );
|
|
$keyphrases = Post::getKeyphrasesDefaults( $post->keyphrases );
|
|
|
|
if ( empty( $keyphrases->focus->keyphrase ) ) {
|
|
return null;
|
|
}
|
|
|
|
$permalink = strtolower( (string) get_permalink( $object['id'] ) );
|
|
$keyphrase = strtolower( (string) $keyphrases->focus->keyphrase );
|
|
$keyphrase = sanitize_title( $keyphrase );
|
|
|
|
if ( strpos( $permalink, $keyphrase ) === false ) {
|
|
return new CheckerResult( 'url-missing-focus-keyword' );
|
|
}
|
|
|
|
return new CheckerResult( 'url-focus-keyword-ok' );
|
|
}
|
|
|
|
/**
|
|
* Analyze the page for URL length issues.
|
|
*
|
|
* @since 4.8.6
|
|
* @version 4.9.4 Handle empty paths and prevent PHP warnings.
|
|
*
|
|
* @return CheckerResult
|
|
*/
|
|
private function analyzeUrlLength() {
|
|
$permalink = (string) get_permalink( $this->object['id'] );
|
|
$path = (string) wp_parse_url( $permalink, PHP_URL_PATH );
|
|
$length = strlen( trim( $path, '/' ) );
|
|
|
|
if ( 50 < $length ) {
|
|
return new CheckerResult( 'url-length-too-long', [ 'length' => $length ] );
|
|
}
|
|
|
|
return new CheckerResult( 'url-length-ok' );
|
|
}
|
|
|
|
/**
|
|
* Analyze the page for Product Schema issues.
|
|
*
|
|
* @since 4.8.6
|
|
*
|
|
* @return CheckerResult|null
|
|
*/
|
|
private function analyzeProductSchema() {
|
|
if ( ! $this->pageParser->isScraping || ( ! aioseo()->helpers->isWooCommerceActive() && ! aioseo()->helpers->isEddActive() ) ) {
|
|
return null;
|
|
}
|
|
|
|
$post = get_post( $this->object['id'] );
|
|
if ( aioseo()->helpers->isWooCommerceActive() && 'product' !== $post->post_type ) {
|
|
return null;
|
|
} elseif ( aioseo()->helpers->isEddActive() && 'download' !== $post->post_type ) {
|
|
return null;
|
|
}
|
|
|
|
$schemas = $this->pageParser->getSchemas();
|
|
$decoded = (array) json_decode( $schemas );
|
|
if ( empty( $schemas ) || empty( $decoded ) ) {
|
|
return new CheckerResult( 'product-schema-missing' );
|
|
}
|
|
|
|
foreach ( $decoded['@graph'] as $graph ) {
|
|
$current = (array) $graph;
|
|
if ( 'Product' === $current['@type'] || 'ProductGroup' === $current['@type'] ) {
|
|
return new CheckerResult( 'product-schema-ok' );
|
|
}
|
|
}
|
|
|
|
return new CheckerResult( 'product-schema-missing' );
|
|
}
|
|
} |