Files
2026-04-28 15:13:50 +02:00

431 lines
15 KiB
PHP

<?php
namespace AIOSEO\Plugin\Pro\Models\SearchStatistics;
// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
use AIOSEO\Plugin\Common\Models as CommonModels;
/**
* The Object DB Model.
* It's called WPObject because Object is a reserved word in PHP.
*
* @since 4.3.0
*/
class WpObject extends CommonModels\Model {
/**
* The name of the table in the database, without the prefix.
*
* @since 4.3.0
*
* @var string
*/
protected $table = 'aioseo_search_statistics_objects';
/**
* Fields that should be numeric values.
*
* @since 4.3.0
*
* @var array
*/
protected $integerFields = [ 'id', 'object_id' ];
/**
* List of fields that should be hidden when serialized.
*
* @since 4.3.0
*
* @var array
*/
protected $hidden = [ 'id' ];
/**
* Fields that should be json encoded on save and decoded on get.
*
* @since 4.5.0
*
* @var array
*/
protected $jsonFields = [ 'inspection_result' ];
/**
* Updates a given row.
*
* @since 4.3.0
*
* @param array $data The new data.
* @return void
*/
public static function update( $data ) {
if ( empty( $data['id'] ) ) {
return;
}
$wpObject = aioseo()->core->db->start( 'aioseo_search_statistics_objects' )
->where( 'id', $data['id'] )
->run()
->model( 'AIOSEO\\Plugin\\Pro\\Models\\SearchStatistics\\WpObject' );
if ( ! $wpObject->exists() ) {
return;
}
try {
$wpObject->set( self::sanitizeAll( array_merge( json_decode( wp_json_encode( $wpObject ), true ), $data ) ) );
$wpObject->save();
} catch ( \Exception $e ) {
// Do nothing. It only exists because the set() method above throws an exception if it fails.
}
}
/**
* Gets the objects from the database.
*
* @since 4.8.2
*
* @param array $args The arguments.
* @return array The object rows.
*/
public static function getObjects( $args = [] ) { // phpcs:disable Generic.Files.LineLength.MaxExceeded
$args = array_merge( [
'filter' => 'all',
'searchTerm' => '',
'additionalFilters' => [],
'paths' => [],
'count' => true,
], $args );
$searchTerm = sanitize_text_field( $args['searchTerm'] );
$orderDir = ! empty( $args['orderDir'] ) ? $args['orderDir'] : 'DESC';
$limit = ! empty( $args['limit'] ) ? intval( $args['limit'] ) : aioseo()->settings->tablePagination['searchStatisticsIndexStatus'];
$offset = ! empty( $args['offset'] ) ? intval( $args['offset'] ) : 0;
$additionalFilters = ! empty( $args['additionalFilters'] ) ? $args['additionalFilters'] : [];
$paths = ! empty( $args['paths'] ) && is_array( $args['paths'] ) ? array_filter( $args['paths'] ) : [];
$postType = $additionalFilters['postType'] ?? '';
$status = $additionalFilters['status'] ?? '';
$robotsTxtState = $additionalFilters['robotsTxtState'] ?? '';
$pageFetchState = $additionalFilters['pageFetchState'] ?? '';
$crawledAs = $additionalFilters['crawledAs'] ?? '';
switch ( $args['orderBy'] ?? '' ) {
case 'title':
$orderBy = 'wp.post_title';
break;
case 'lastCrawlTime':
$orderBy = 'aio.last_crawl_time';
break;
default:
$orderBy = 'aio.created';
}
/**
* Fetching objects from our table is probably the best way since it's constantly being updated {@see Objects::scanForPosts()},
* and it already has some of the necessary data (e.g. the "inspection_result" column).
*/
$query = aioseo()->core->db->start( 'aioseo_search_statistics_objects as aio' )
->select( 'aio.id, aio.object_id, aio.object_type, aio.object_subtype, aio.object_path, aio.inspection_result, aio.inspection_result_date, aio.verdict, aio.robots_txt_state, aio.indexing_state, aio.page_fetch_state, aio.coverage_state, aio.crawled_as, aio.last_crawl_time, wp.post_title, wp.post_type' )
->join( 'posts as wp', 'aio.object_id = wp.ID', 'INNER' )
->where( 'aio.object_type', 'post' )
->whereIn( 'aio.object_subtype', aioseo()->helpers->getPublicPostTypes( true ) )
->orderBy( "$orderBy $orderDir" )
->limit( $limit, $offset );
$totalQuery = aioseo()->core->db->noConflict()->start( 'aioseo_search_statistics_objects as aio' )
->join( 'posts as wp', 'aio.object_id = wp.ID AND aio.object_type = "post"', 'INNER' )
->where( 'aio.object_type', 'post' )
->whereIn( 'aio.object_subtype', aioseo()->helpers->getPublicPostTypes( true ) );
if ( $paths ) {
$sanitizedPaths = array_map( 'sanitize_text_field', array_unique( $paths ) );
$query->whereIn( 'object_path_hash', array_map( 'sha1', $sanitizedPaths ) );
$totalQuery->whereIn( 'object_path_hash', array_map( 'sha1', $sanitizedPaths ) );
}
if ( $searchTerm ) {
$query->whereLike( 'wp.post_title', '%' . $searchTerm . '%', true );
$totalQuery->whereLike( 'wp.post_title', '%' . $searchTerm . '%', true );
}
if ( $postType ) {
$query->where( 'aio.object_subtype', $postType );
$totalQuery->where( 'aio.object_subtype', $postType );
}
// {@see \AIOSEO\Plugin\Common\SearchStatistics\IndexStatus::getUiOptions()} for all possible status.
if ( $status ) {
if ( 'submitted' === $status ) {
$query->where( 'aio.verdict', 'PASS' );
$totalQuery->where( 'aio.verdict', 'PASS' );
}
if (
'crawled' === $status ||
'discovered' === $status
) {
$query->whereRaw( 'LOWER(aio.coverage_state) LIKE \'%' . $status . '%\'' );
$totalQuery->whereRaw( 'LOWER(aio.coverage_state) LIKE \'%' . $status . '%\'' );
}
if ( 'empty' === $status ) {
$query->whereRaw( '( aio.coverage_state IS NULL OR aio.coverage_state = "" )' );
$totalQuery->whereRaw( '( aio.coverage_state IS NULL OR aio.coverage_state = "" )' );
}
// This is supposed to cover all other possible statuses.
if ( 'unknown|excluded|invalid|error' === $status ) {
$query->whereRaw( "aio.coverage_state IS NOT NULL AND aio.coverage_state != '' AND aio.verdict != 'PASS' AND LOWER(aio.coverage_state) NOT LIKE '%crawled%' AND LOWER(aio.coverage_state) NOT LIKE '%discovered%'" );
$totalQuery->whereRaw( "aio.coverage_state IS NOT NULL AND aio.coverage_state != '' AND aio.verdict != 'PASS' AND LOWER(aio.coverage_state) NOT LIKE '%crawled%' AND LOWER(aio.coverage_state) NOT LIKE '%discovered%'" );
}
}
if ( $robotsTxtState ) {
$query->where( 'aio.robots_txt_state', $robotsTxtState );
$totalQuery->where( 'aio.robots_txt_state', $robotsTxtState );
}
if ( $pageFetchState ) {
if ( false !== strpos( $pageFetchState, ',' ) ) {
// It might be SOFT_404,BLOCKED_ROBOTS_TXT,NOT_FOUND...
$pageFetchState = explode( ',', $pageFetchState );
$query->whereIn( 'aio.page_fetch_state', $pageFetchState );
$totalQuery->whereIn( 'aio.page_fetch_state', $pageFetchState );
} else {
$query->where( 'aio.page_fetch_state', $pageFetchState );
$totalQuery->where( 'aio.page_fetch_state', $pageFetchState );
}
}
if ( $crawledAs ) {
$query->where( 'aio.crawled_as', $crawledAs );
$totalQuery->where( 'aio.crawled_as', $crawledAs );
}
$total = $args['count'] ? $totalQuery->count() : null;
$rows = $query->run()->result();
return [
'rows' => array_values( $rows ),
'totals' => ! is_null( $total )
? [
'total' => $total,
'pages' => $pages = ( 0 === $total ? 1 : ceil( $total / $limit ) ),
'page' => min( $pages, 0 === $offset ? 1 : ( $offset / $limit ) + 1 )
]
: []
];
}
/**
* Gets a row by its path.
*
* @since 4.5.0
*
* @param string $path The path.
* @return WpObject|null The object or null if not found.
*/
public static function getObject( $path ) {
return aioseo()->core->db->start( 'aioseo_search_statistics_objects' )
->where( 'object_path_hash', sha1( sanitize_text_field( $path ) ) )
->run()
->model( 'AIOSEO\\Plugin\\Pro\\Models\\SearchStatistics\\WpObject' );
}
/**
* Gets a row by the given field => value.
*
* @since 4.5.3
*
* @param string $field The field to look for.
* @param string $value The value to look for.
* @return WpObject|null The object or null if not found.
*/
public static function getObjectBy( $field, $value ) {
$wpObject = aioseo()->core->db->start( 'aioseo_search_statistics_objects' );
switch ( $field ) {
case 'path':
$wpObject = $wpObject->where( 'object_path_hash', sha1( sanitize_text_field( $value ) ) );
break;
case 'post_id':
$wpObject = $wpObject->where( 'object_id', $value );
$wpObject = $wpObject->where( 'object_type', 'post' );
break;
case 'term_id':
$wpObject = $wpObject->where( 'object_id', $value );
$wpObject = $wpObject->where( 'object_type', 'term' );
break;
default:
$wpObject = $wpObject->where( $field, $value );
}
$wpObject = $wpObject->run()->model( 'AIOSEO\\Plugin\\Pro\\Models\\SearchStatistics\\WpObject' );
return $wpObject;
}
/**
* Checks if the inspection result is valid.
*
* @since 4.6.1
* @version 4.8.4 Changed to use the static method.
*
* @param object $row The row to check.
* @return bool Whether the inspection result is valid.
*/
public static function isInspectionResultValid( $row ) {
// If there is no inspection result.
if ( empty( $row->inspection_result ) ) {
return false;
}
// If the inspection result is older than 30 days.
if ( empty( $row->inspection_result_date ) || strtotime( $row->inspection_result_date ) < strtotime( '-1 month' ) ) {
return false;
}
// If this object is not indexed and the inspection result is older than 1 day.
if ( empty( $row->verdict ) || ( 'PASS' !== $row->verdict && strtotime( $row->inspection_result_date ) < strtotime( '-1 day' ) ) ) {
return false;
}
return true;
}
/**
* Bulk inserts a set of rows.
*
* @since 4.3.0
*
* @param array $rows The rows to insert.
* @return void
*/
public static function bulkInsert( $rows ) {
$columns = [
'object_id',
'object_type',
'object_subtype',
'object_path',
'object_path_hash',
'inspection_result',
'inspection_result_date',
'verdict',
'robots_txt_state',
'indexing_state',
'page_fetch_state',
'coverage_state',
'crawled_as',
'last_crawl_time',
'created',
'updated'
];
$currentDate = gmdate( 'Y-m-d H:i:s' );
$insertRows = [];
foreach ( $rows as $row ) {
$row = json_decode( wp_json_encode( $row ), true );
if ( empty( $row['object_path'] ) ) {
continue;
}
$insertRows[] = array_merge( array_values( self::sanitizeAll( $row ) ), [ $currentDate, $currentDate ] );
}
$onDuplicate = [
'object_id' => 'VALUES(`object_id`)',
'object_type' => 'VALUES(`object_type`)',
'object_subtype' => 'VALUES(`object_subtype`)',
'inspection_result_date' => "'0000-00-00 00:00:00'",
'updated' => 'VALUES(`updated`)'
];
aioseo()->core->db->bulkInsert( 'aioseo_search_statistics_objects', $columns, $insertRows, [
'onDuplicate' => $onDuplicate
] );
}
/**
* Parses an object row.
*
* @since {row}
*
* @param object $row The row to format.
* @return array The formatted row.
*/
public static function parseObject( $row ) {
$parsed = [];
$parsed['id'] = intval( $row->id );
$parsed['objectTitle'] = aioseo()->helpers->decodeHtmlEntities( $row->post_title );
$parsed['objectId'] = intval( $row->object_id );
$parsed['editLink'] = get_edit_post_link( $parsed['objectId'], 'url' );
$parsed['permalink'] = get_permalink( $parsed['objectId'] );
$inspectionResult = json_decode( (string) $row->inspection_result, true );
$indexStatusResult = $inspectionResult['indexStatusResult'] ?? [];
$postTypeLabels = aioseo()->helpers->getPostTypeLabels( $row->object_subtype );
$parsed['postTypeLabels'] = [
'singular' => $postTypeLabels->singular_name ?? ''
];
$parsed['path'] = $row->object_path;
$parsed['verdict'] = $row->verdict;
$parsed['robotsTxtState'] = $row->robots_txt_state;
$parsed['indexingState'] = $row->indexing_state;
$parsed['pageFetchState'] = $row->page_fetch_state;
$parsed['coverageState'] = $row->coverage_state;
$parsed['crawledAs'] = $row->crawled_as;
$parsed['lastCrawlTime'] = aioseo()->helpers->dateToWpFormat( $row->last_crawl_time );
$parsed['userCanonical'] = ! empty( $indexStatusResult['userCanonical'] ) ? $indexStatusResult['userCanonical'] : null;
$parsed['googleCanonical'] = ! empty( $indexStatusResult['googleCanonical'] ) ? $indexStatusResult['googleCanonical'] : null;
$parsed['sitemap'] = ! empty( $indexStatusResult['sitemap'] ) ? $indexStatusResult['sitemap'] : [];
$parsed['referringUrls'] = ! empty( $indexStatusResult['referringUrls'] )
? array_map( [ aioseo()->helpers, 'decodeUrl' ], $indexStatusResult['referringUrls'] )
: [];
$parsed['richResultsResult'] = $inspectionResult['richResultsResult'] ?? null;
$parsed['inspectionResultLink'] = $inspectionResult['inspectionResultLink'] ?? null;
$parsed['isInspectionValid'] = self::isInspectionResultValid( $row );
if ( $parsed['permalink'] ) {
$parsed['richResultsTestLink'] = add_query_arg( [
'url' => $parsed['permalink']
], 'https://search.google.com/test/rich-results' );
}
return $parsed;
}
/**
* Sanitize all the Model field values.
*
* @since 4.8.2
*
* @param array $fields All the field values.
* @return array The sanitized field values.
*/
public static function sanitizeAll( $fields ) {
$sanitized = [];
$inspectionResult = $fields['inspection_result'] ?? [];
$isr = $inspectionResult['indexStatusResult'] ?? [];
$sanitized['object_id'] = ! empty( $fields['object_id'] ) ? (int) $fields['object_id'] : null;
$sanitized['object_type'] = ! empty( $fields['object_type'] ) ? sanitize_text_field( $fields['object_type'] ) : null;
$sanitized['object_subtype'] = ! empty( $fields['object_subtype'] ) ? sanitize_text_field( $fields['object_subtype'] ) : null;
$sanitized['object_path'] = ! empty( $fields['object_path'] ) ? sanitize_text_field( $fields['object_path'] ) : null;
$sanitized['object_path_hash'] = ! empty( $sanitized['object_path'] ) ? sha1( $sanitized['object_path'] ) : null;
$sanitized['inspection_result'] = ! empty( $inspectionResult ) ? $inspectionResult : null;
$sanitized['inspection_result_date'] = ! empty( $fields['inspection_result_date'] ) ? $fields['inspection_result_date'] : null;
$sanitized['verdict'] = ! empty( $isr['verdict'] ) ? sanitize_text_field( $isr['verdict'] ) : null;
$sanitized['robots_txt_state'] = ! empty( $isr['robotsTxtState'] ) ? sanitize_text_field( $isr['robotsTxtState'] ) : null;
$sanitized['indexing_state'] = ! empty( $isr['indexingState'] ) ? sanitize_text_field( $isr['indexingState'] ) : null;
$sanitized['page_fetch_state'] = ! empty( $isr['pageFetchState'] ) ? sanitize_text_field( $isr['pageFetchState'] ) : null;
$sanitized['coverage_state'] = ! empty( $isr['coverageState'] ) ? sanitize_text_field( $isr['coverageState'] ) : null;
$sanitized['crawled_as'] = ! empty( $isr['crawledAs'] ) ? sanitize_text_field( $isr['crawledAs'] ) : null;
$sanitized['last_crawl_time'] = ! empty( $isr['lastCrawlTime'] ) ? date( 'Y-m-d H:i:s', strtotime( $isr['lastCrawlTime'] ) ) : null;
return $sanitized;
}
}