PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / trunk
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO vtrunk
2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 All 51 releases
thinkrank / includes / seo / class-duplicate-snippets.php

class-duplicate-snippets.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO trunk, at includes/seo/class-duplicate-snippets.php

327 lines 12.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Sitewide duplicate title and meta description report.
4 *
5 * @package ThinkRank\SEO
6 * @since 2.10.0
7 */
8
9 declare(strict_types=1);
10
11 namespace ThinkRank\SEO;
12
13 // Prevent direct access
14 if (!defined('ABSPATH')) {
15 exit;
16 }
17
18 /**
19 * Duplicate Snippets
20 *
21 * Answers one question across the whole site: which published pages render the
22 * same SEO title, or the same meta description, as another one (#564). Two
23 * pages with one title between them compete for the same result, and on a
24 * template-heavy site a single bad pattern stamps the same metadata on fifty
25 * archive pages without anything saying so.
26 *
27 * Three decisions shape this class.
28 *
29 * **It does not crawl, and it is not part of the Site SEO Analyzer.** The
30 * analyzer is deliberately crawl-free *and* sample-bounded — it reads the most
31 * recent hundred posts — and a duplicate outside a bounded sample is invisible
32 * by definition, so putting this there would have answered the question wrong
33 * rather than expensively. Instead it reads {@see Snippet_Index}, which already
34 * stores a grouping key per post, and finds collisions by grouping in SQL over
35 * every post rather than a sample.
36 *
37 * **Published only.** A draft is not on the web and cannot compete with
38 * anything, so counting one as a duplicate would report a problem that does not
39 * exist. Bulk Snippets still shows the per-row duplicate badge under whichever
40 * status filter is in use; this report is the sitewide number, and the sitewide
41 * number is about what is live.
42 *
43 * **On demand, not scheduled.** The scan is driven by the screen asking for it,
44 * a bounded batch at a time, and the result is cached against the index
45 * generation so a second look is free until something changes. A scheduled
46 * recheck is the Pro half of the same feature (see `Plan_Config`), which is why
47 * the capability is read here rather than assumed.
48 *
49 * @since 2.10.0
50 */
51 class Duplicate_Snippets {
52
53 /**
54 * Option holding the cached report.
55 */
56 private const CACHE_OPTION = 'thinkrank_duplicate_snippets_report';
57
58 /**
59 * Statuses the report covers. See the class docblock: a page that is not
60 * published is not competing with anything.
61 *
62 * @var string[]
63 */
64 private const STATUSES = ['publish'];
65
66 /**
67 * Groups reported per kind. A site with more than this many distinct
68 * duplicated titles has a template problem, not fifty separate ones, and
69 * the counts above the list still report the true total.
70 */
71 public const MAX_GROUPS = 25;
72
73 /**
74 * Posts listed inside one group. The group's count is exact; this only
75 * bounds how many of them are named.
76 */
77 public const MAX_MEMBERS = 10;
78
79 /**
80 * The report, from cache when the index has not moved since it was built.
81 *
82 * `$refresh` rescans rather than only regrouping. Regrouping reads the same
83 * index entries the cache was built from, so an entry the invalidation
84 * hooks missed (a term, option or plugin filter that changed what a title
85 * renders without saving the post) survived a "refresh" untouched, which
86 * is the one case a user reaches for the button. Bumping the generation
87 * makes every entry stale, and the next calls rebuild them a bounded
88 * batch at a time as usual; callers continue without `$refresh`, or each
89 * call would start the scan over.
90 *
91 * @since 2.10.0 `$refresh` rescans the site.
92 *
93 * @param bool $refresh Discard every index entry and rescan.
94 * @return array{
95 * pending: int,
96 * scanned: int,
97 * counts: array<string,int>,
98 * groups: array<string,array<int,array<string,mixed>>>,
99 * truncated: array<string,bool>,
100 * generated_at: int,
101 * scheduled: bool
102 * }
103 */
104 public static function report(bool $refresh = false): array {
105 $post_types = Global_SEO_Post_Types::allowed();
106
107 if ($refresh) {
108 Snippet_Index::bump_generation();
109 }
110
111 // Keep filling the index before answering. Each call is bounded, and
112 // `pending` tells the caller whether the answer is complete yet.
113 $pending = Snippet_Index::refresh_sitewide($post_types, self::STATUSES);
114
115 // What the answer depends on. The generation alone is not enough: it
116 // only moves when a *global* input changes, and one post's title
117 // edited to match another's changes this report without touching it.
118 // The revision covers that, and the count covers a post leaving the
119 // scope entirely, which no rebuild would ever report.
120 $signature = [
121 'generation' => Snippet_Index::generation(),
122 'revision' => Snippet_Index::revision(),
123 'scanned' => Snippet_Index::current_count($post_types, self::STATUSES),
124 ];
125
126 $cached = get_option(self::CACHE_OPTION, null);
127 if (!$refresh
128 && is_array($cached)
129 && 0 === $pending
130 && ($cached['signature'] ?? null) === $signature
131 ) {
132 return self::present($cached, 0);
133 }
134
135 $report = [
136 'signature' => $signature,
137 'scanned' => $signature['scanned'],
138 'counts' => [],
139 'groups' => [],
140 'truncated' => [],
141 'built_at' => time(),
142 ];
143
144 foreach (Snippet_Issues::grouped() as $issue) {
145 $which = Snippet_Issues::DUPLICATE_TITLE === $issue ? 'title' : 'description';
146
147 // Totals come from their own count rather than from the listed
148 // groups, which are capped — otherwise a site with 200 duplicated
149 // titles would report 25 groups' worth and call it the total.
150 $totals = Snippet_Index::duplicate_totals($post_types, self::STATUSES, $which);
151
152 $report['counts'][$issue] = $totals['posts'];
153 $report['group_counts'][$issue] = $totals['groups'];
154 $report['truncated'][$issue] = $totals['groups'] > self::MAX_GROUPS;
155 $report['groups'][$issue] = self::describe(
156 Snippet_Index::duplicate_groups(
157 $post_types,
158 self::STATUSES,
159 $which,
160 self::MAX_MEMBERS,
161 self::MAX_GROUPS
162 ),
163 $which
164 );
165 }
166
167 // Only a complete scan is worth caching: a partial one would be served
168 // as the answer long after the index finished filling.
169 if (0 === $pending) {
170 update_option(self::CACHE_OPTION, $report, false);
171 }
172
173 return self::present($report, $pending);
174 }
175
176 /**
177 * Shape a stored report for its caller.
178 *
179 * @param array<string,mixed> $report Stored report.
180 * @param int $pending Entries still to be indexed.
181 * @return array<string,mixed>
182 */
183 private static function present(array $report, int $pending): array {
184 $counts = [];
185 $group_counts = [];
186 $groups = [];
187 $truncated = [];
188
189 foreach (Snippet_Issues::grouped() as $issue) {
190 $counts[$issue] = (int) ($report['counts'][$issue] ?? 0);
191 $group_counts[$issue] = (int) ($report['group_counts'][$issue] ?? 0);
192 $groups[$issue] = array_values((array) ($report['groups'][$issue] ?? []));
193 $truncated[$issue] = (bool) ($report['truncated'][$issue] ?? false);
194 }
195
196 $groups = self::add_viewer_fields($groups);
197
198 return [
199 // Above zero means the scan has not covered the whole site yet, so
200 // the numbers below are a floor rather than the answer.
201 'pending' => max(0, $pending),
202 'scanned' => (int) ($report['scanned'] ?? 0),
203 // Posts caught up in a duplicate, per issue.
204 'counts' => $counts,
205 // Distinct values being duplicated, per issue.
206 'group_counts' => $group_counts,
207 'groups' => $groups,
208 'truncated' => $truncated,
209 'generated_at' => (int) ($report['built_at'] ?? 0),
210 'scheduled' => \ThinkRank\Core\Plan_Config::can('scheduled', 'duplicate_snippets'),
211 ];
212 }
213
214 /**
215 * Add the two fields that belong to the reader rather than to the report.
216 *
217 * Answered for the current user on every request, cache hit or not. The
218 * report is one option shared by every user, so a `can_edit` resolved while
219 * building it would be handed to whoever asked next: a rebuild triggered by
220 * someone without edit rights cached an empty `edit_url`, and the
221 * administrator who opened the screen after them was linked to the public
222 * page instead of the editor.
223 *
224 * @param array<string,array<int,array<string,mixed>>> $groups Groups per issue.
225 * @return array<string,array<int,array<string,mixed>>>
226 */
227 private static function add_viewer_fields(array $groups): array {
228 $ids = [];
229 foreach ($groups as $found) {
230 foreach ($found as $group) {
231 foreach ((array) ($group['posts'] ?? []) as $post) {
232 $ids[] = (int) ($post['post_id'] ?? 0);
233 }
234 }
235 }
236
237 $ids = array_values(array_unique(array_filter($ids)));
238 if (!empty($ids)) {
239 _prime_post_caches($ids, false, true);
240 }
241
242 foreach ($groups as $issue => $found) {
243 foreach ($found as $i => $group) {
244 foreach ((array) ($group['posts'] ?? []) as $j => $post) {
245 $post_id = (int) ($post['post_id'] ?? 0);
246
247 $groups[$issue][$i]['posts'][$j]['can_edit'] = $post_id > 0
248 && current_user_can('edit_post', $post_id);
249 $groups[$issue][$i]['posts'][$j]['edit_url'] = $post_id > 0
250 ? (string) get_edit_post_link($post_id, 'raw')
251 : '';
252 }
253 }
254 }
255
256 return $groups;
257 }
258
259 /**
260 * Turn grouped IDs into something a reader can act on: the value the group
261 * shares, and a link to each page carrying it.
262 *
263 * The shared value is resolved once per group rather than once per post —
264 * a duplicate group is by definition one value — so a report of 25 groups
265 * resolves 25 snippets, not 250.
266 *
267 * Deliberately carries nothing that depends on *who is asking*: this array
268 * is what gets stored in the cache option. {@see self::add_viewer_fields()}
269 * resolves `edit_url` and `can_edit` per request instead.
270 *
271 * @param array<int,array{key:string, post_ids:int[], total:int}> $groups Groups.
272 * @param string $which 'title' or 'description'.
273 * @return array<int,array<string,mixed>>
274 */
275 private static function describe(array $groups, string $which): array {
276 $all_ids = [];
277 foreach ($groups as $group) {
278 $all_ids = array_merge($all_ids, $group['post_ids']);
279 }
280
281 if (!empty($all_ids)) {
282 _prime_post_caches(array_values(array_unique($all_ids)), false, true);
283 }
284
285 $described = [];
286 foreach ($groups as $group) {
287 $posts = [];
288 foreach ($group['post_ids'] as $post_id) {
289 $post = get_post($post_id);
290 if (!$post instanceof \WP_Post) {
291 continue;
292 }
293
294 $posts[] = [
295 'post_id' => (int) $post->ID,
296 'post_title' => html_entity_decode(get_the_title($post), ENT_QUOTES, 'UTF-8'),
297 'post_type' => $post->post_type,
298 'permalink' => (string) get_permalink($post),
299 ];
300 }
301
302 if (empty($posts)) {
303 continue;
304 }
305
306 $first = (int) $posts[0]['post_id'];
307 $described[] = [
308 'key' => $group['key'],
309 // Decoded, like post_title above: the resolved value is still
310 // HTML (`Foo &#038; Bar`), and both the card and the ability
311 // hand it on as text, so the entity was shown as typed.
312 'value' => \ThinkRank\Core\Seo_Text::as_displayed(
313 'title' === $which
314 ? Pattern_Resolver::effective_title($first)
315 : Pattern_Resolver::effective_description($first)
316 ),
317 'total' => $group['total'],
318 'posts' => $posts,
319 // True when the group has more members than are named here.
320 'partial' => $group['total'] > count($posts),
321 ];
322 }
323
324 return $described;
325 }
326 }
327