| 1 |
<?php |
| 2 |
/** |
| 3 |
* Get duplicate snippets ability. |
| 4 |
* |
| 5 |
* @package ThinkRank\Abilities\Content |
| 6 |
*/ |
| 7 |
|
| 8 |
declare(strict_types=1); |
| 9 |
|
| 10 |
namespace ThinkRank\Abilities\Content; |
| 11 |
|
| 12 |
use ThinkRank\Abilities\Ability_Base; |
| 13 |
use ThinkRank\SEO\Duplicate_Snippets; |
| 14 |
use ThinkRank\SEO\Snippet_Issues; |
| 15 |
|
| 16 |
if ( ! defined( 'ABSPATH' ) ) { |
| 17 |
exit; // Exit if accessed directly. |
| 18 |
} |
| 19 |
|
| 20 |
/** |
| 21 |
* Reports published pages that render the same SEO title, or the same meta |
| 22 |
* description, as another page anywhere on the site (#564). |
| 23 |
* |
| 24 |
* Distinct from list-snippet-issues, which answers "what is wrong with this |
| 25 |
* post type's snippets" one post type at a time. A duplicate is a relationship |
| 26 |
* between two pages that are often different post types, so it needs a report |
| 27 |
* whose scope is the whole site and whose unit is the repeated value rather |
| 28 |
* than the post. |
| 29 |
*/ |
| 30 |
class Get_Duplicate_Snippets extends Ability_Base { |
| 31 |
/** |
| 32 |
* Constructor. |
| 33 |
*/ |
| 34 |
public function __construct() { |
| 35 |
$this->id = 'thinkrank/get-duplicate-snippets'; |
| 36 |
$this->label = __( 'Get ThinkRank Duplicate Snippets', 'thinkrank' ); |
| 37 |
$this->description = __( 'Find published pages across every post type that render the same search title, or the same meta description, as another page. Groups them by the repeated value, largest group first, so one bad post type template shows up as a single finding rather than fifty. Covers published content only, since a draft cannot compete in search. Returns the value, how many pages carry it, and which pages they are. Fix one with update-post-seo.', 'thinkrank' ); |
| 38 |
} |
| 39 |
|
| 40 |
/** |
| 41 |
* {@inheritDoc} |
| 42 |
* |
| 43 |
* @return array<string, bool|float|string> |
| 44 |
*/ |
| 45 |
public function get_annotations() { |
| 46 |
return [ |
| 47 |
'readonly' => true, |
| 48 |
'destructive' => false, |
| 49 |
'idempotent' => true, |
| 50 |
'priority' => 1.0, |
| 51 |
'openWorldHint' => false, |
| 52 |
]; |
| 53 |
} |
| 54 |
|
| 55 |
/** |
| 56 |
* {@inheritDoc} |
| 57 |
* |
| 58 |
* @return array<string, mixed> |
| 59 |
*/ |
| 60 |
public function get_input_schema() { |
| 61 |
return [ |
| 62 |
'type' => 'object', |
| 63 |
'additionalProperties' => false, |
| 64 |
'properties' => [ |
| 65 |
'issue' => [ |
| 66 |
'type' => 'string', |
| 67 |
'enum' => array_merge( [ '' ], Snippet_Issues::grouped() ), |
| 68 |
'default' => '', |
| 69 |
'description' => __( 'Limit the groups to one kind. Empty returns both. Counts are always returned for both.', 'thinkrank' ), |
| 70 |
], |
| 71 |
'refresh' => [ |
| 72 |
'type' => 'boolean', |
| 73 |
'default' => false, |
| 74 |
'description' => __( 'Discard the index and rescan every published page. The report already updates whenever a title, description or template changes, so this is only needed when it looks stale. The rescan runs in batches: pending rises, so call again without refresh until pending is zero.', 'thinkrank' ), |
| 75 |
], |
| 76 |
], |
| 77 |
]; |
| 78 |
} |
| 79 |
|
| 80 |
/** |
| 81 |
* {@inheritDoc} |
| 82 |
* |
| 83 |
* @return array<string, mixed> |
| 84 |
*/ |
| 85 |
public function get_output_schema() { |
| 86 |
return [ |
| 87 |
'type' => 'object', |
| 88 |
'properties' => [ |
| 89 |
'pending' => [ |
| 90 |
'type' => 'integer', |
| 91 |
'description' => __( 'Pages not scanned yet. When above zero the counts are a floor, not the answer; call again to continue.', 'thinkrank' ), |
| 92 |
], |
| 93 |
'scanned' => [ |
| 94 |
'type' => 'integer', |
| 95 |
'description' => __( 'Published pages the report covers.', 'thinkrank' ), |
| 96 |
], |
| 97 |
'counts' => [ |
| 98 |
'type' => 'object', |
| 99 |
'description' => __( 'Pages caught up in a duplicate, per issue.', 'thinkrank' ), |
| 100 |
], |
| 101 |
'group_counts' => [ |
| 102 |
'type' => 'object', |
| 103 |
'description' => __( 'Distinct repeated values, per issue.', 'thinkrank' ), |
| 104 |
], |
| 105 |
'truncated' => [ |
| 106 |
'type' => 'object', |
| 107 |
'description' => __( 'Per issue, whether there are more repeated values than the groups listed.', 'thinkrank' ), |
| 108 |
], |
| 109 |
'groups' => [ |
| 110 |
'type' => 'object', |
| 111 |
'description' => __( 'Repeated values per issue, largest group first.', 'thinkrank' ), |
| 112 |
], |
| 113 |
], |
| 114 |
]; |
| 115 |
} |
| 116 |
|
| 117 |
/** |
| 118 |
* Execute ability. |
| 119 |
* |
| 120 |
* @param array<string, mixed> $input Ability input payload. |
| 121 |
* @return array<string, mixed>|\WP_Error |
| 122 |
*/ |
| 123 |
public function execute( $input ) { |
| 124 |
$issue = isset( $input['issue'] ) ? sanitize_key( (string) $input['issue'] ) : ''; |
| 125 |
$refresh = ! empty( $input['refresh'] ); |
| 126 |
|
| 127 |
if ( '' !== $issue && ! in_array( $issue, Snippet_Issues::grouped(), true ) ) { |
| 128 |
return new \WP_Error( |
| 129 |
'thinkrank_invalid_issue', |
| 130 |
__( 'Unknown issue. Duplicates are reported for duplicate_title and duplicate_description.', 'thinkrank' ), |
| 131 |
[ 'status' => 400 ] |
| 132 |
); |
| 133 |
} |
| 134 |
|
| 135 |
$report = Duplicate_Snippets::report( $refresh ); |
| 136 |
|
| 137 |
$groups = []; |
| 138 |
foreach ( $report['groups'] as $key => $found ) { |
| 139 |
if ( '' !== $issue && $key !== $issue ) { |
| 140 |
continue; |
| 141 |
} |
| 142 |
|
| 143 |
$groups[ $key ] = array_map( |
| 144 |
static function ( array $group ): array { |
| 145 |
// The grouping hash is an implementation detail of the |
| 146 |
// index; the value and the pages are the finding. |
| 147 |
return [ |
| 148 |
'value' => $group['value'], |
| 149 |
'total' => $group['total'], |
| 150 |
'pages' => array_map( |
| 151 |
static function ( array $post ): array { |
| 152 |
return [ |
| 153 |
'post_id' => $post['post_id'], |
| 154 |
'post_title' => $post['post_title'], |
| 155 |
'post_type' => $post['post_type'], |
| 156 |
'permalink' => $post['permalink'], |
| 157 |
]; |
| 158 |
}, |
| 159 |
$group['posts'] |
| 160 |
), |
| 161 |
// True when the group has more pages than are named. |
| 162 |
'partial' => $group['partial'], |
| 163 |
]; |
| 164 |
}, |
| 165 |
$found |
| 166 |
); |
| 167 |
} |
| 168 |
|
| 169 |
return [ |
| 170 |
'pending' => $report['pending'], |
| 171 |
'scanned' => $report['scanned'], |
| 172 |
'counts' => $report['counts'], |
| 173 |
'group_counts' => $report['group_counts'], |
| 174 |
'truncated' => $report['truncated'], |
| 175 |
'groups' => $groups, |
| 176 |
]; |
| 177 |
} |
| 178 |
} |
| 179 |
|