PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
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-snippet-issues.php

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

386 lines 14.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Snippet issue rules for Bulk Snippets.
4 *
5 * @package ThinkRank\SEO
6 * @since 2.8.0
7 */
8
9 declare(strict_types=1);
10
11 namespace ThinkRank\SEO;
12
13 use ThinkRank\AI\SEOScoreCalculator;
14 use ThinkRank\Core\Seo_Text;
15
16 // Prevent direct access
17 if (!defined('ABSPATH')) {
18 exit;
19 }
20
21 /**
22 * Snippet Issues
23 *
24 * Decides which problems a post's search snippet has — the flags behind Bulk
25 * Snippets' "show only posts with a problem" filter (#727).
26 *
27 * Two definitions matter and are easy to get wrong:
28 *
29 * - **Empty** means no value of the post's own. An empty
30 * `_thinkrank_seo_title` is not "no title": the post inherits its post type's
31 * template, and that rendered title is what Google sees. So the empty flags
32 * answer "nobody wrote one for this post", and the length flags are judged
33 * on the *effective* value — the same value the editor score judges, via
34 * {@see Pattern_Resolver::effective_title()}.
35 * - **Length** bands are the editor's, read from {@see SEOScoreCalculator}, so
36 * a post this screen calls "too long" is one the editor also calls too long.
37 *
38 * The rule evaluation is a pure function of an already-loaded row, so it is
39 * testable without WordPress and cheap to run over a whole post type.
40 *
41 * @since 2.8.0
42 */
43 class Snippet_Issues {
44
45 public const EMPTY_TITLE = 'empty_title';
46 public const EMPTY_DESCRIPTION = 'empty_description';
47 public const TITLE_TOO_SHORT = 'title_too_short';
48 public const TITLE_TOO_LONG = 'title_too_long';
49 public const DESCRIPTION_TOO_SHORT = 'description_too_short';
50 public const DESCRIPTION_TOO_LONG = 'description_too_long';
51 public const NO_FOCUS_KEYWORD = 'no_focus_keyword';
52 public const NOINDEX = 'noindex';
53 public const DUPLICATE_TITLE = 'duplicate_title';
54 public const DUPLICATE_DESCRIPTION = 'duplicate_description';
55
56 /**
57 * Every issue, in the order the filter chips show them.
58 *
59 * @since 2.8.0
60 *
61 * @return string[]
62 */
63 public static function all(): array {
64 return [
65 self::EMPTY_TITLE,
66 self::EMPTY_DESCRIPTION,
67 self::TITLE_TOO_SHORT,
68 self::TITLE_TOO_LONG,
69 self::DESCRIPTION_TOO_SHORT,
70 self::DESCRIPTION_TOO_LONG,
71 self::NO_FOCUS_KEYWORD,
72 self::NOINDEX,
73 self::DUPLICATE_TITLE,
74 self::DUPLICATE_DESCRIPTION,
75 ];
76 }
77
78 /**
79 * Issues decided by comparing a post against the rest of the site, rather
80 * than by looking at the post alone.
81 *
82 * These carry no bit in the stored flags: the index stores a grouping key
83 * for each and the duplicates are found by grouping at query time, so
84 * fixing one of two duplicates clears the other without a write to it.
85 *
86 * @since 2.10.0
87 *
88 * @return string[]
89 */
90 public static function grouped(): array {
91 return [self::DUPLICATE_TITLE, self::DUPLICATE_DESCRIPTION];
92 }
93
94 /**
95 * Bit per issue that depends on the post alone.
96 *
97 * The grouped issues ({@see self::grouped()}) are not here. They sit last
98 * in {@see self::all()} so that every other issue keeps the bit it has
99 * always had — the order is part of the stored format, so append, never
100 * reorder.
101 *
102 * @since 2.8.0
103 *
104 * @return array<string,int> Issue => bit.
105 */
106 public static function flag_bits(): array {
107 $grouped = self::grouped();
108
109 $bits = [];
110 $position = 0;
111 foreach (self::all() as $issue) {
112 if (in_array($issue, $grouped, true)) {
113 continue;
114 }
115 $bits[$issue] = 1 << $position;
116 $position++;
117 }
118
119 return $bits;
120 }
121
122 /**
123 * Encode issues as a bitmask. Grouped issues are ignored (see flag_bits()).
124 *
125 * @since 2.8.0
126 *
127 * @param string[] $issues Issue slugs.
128 * @return int
129 */
130 public static function to_flags(array $issues): int {
131 $bits = self::flag_bits();
132 $flags = 0;
133 foreach ($issues as $issue) {
134 $flags |= $bits[$issue] ?? 0;
135 }
136
137 return $flags;
138 }
139
140 /**
141 * Load everything the rules need for one post.
142 *
143 * Duplicate status is not included — it needs the rest of the site.
144 *
145 * @since 2.8.0
146 *
147 * @param \WP_Post $post Post.
148 * @return array<string,mixed>
149 */
150 public static function snapshot(\WP_Post $post): array {
151 $post_id = (int) $post->ID;
152 $keywords = Focus_Keywords::get($post_id);
153 $robots = self::robots_state($post_id, $post->post_type);
154
155 return [
156 'post' => $post,
157 'raw_title' => (string) get_post_meta($post_id, '_thinkrank_seo_title', true),
158 'raw_description' => (string) get_post_meta($post_id, '_thinkrank_meta_description', true),
159 'effective_title' => Pattern_Resolver::effective_title($post_id),
160 'effective_description' => Pattern_Resolver::effective_description($post_id),
161 'focus_keyword' => (string) ($keywords[0] ?? ''),
162 'noindex' => $robots['noindex'],
163 'noindex_source' => $robots['source'],
164 ];
165 }
166
167 /**
168 * Which issues a snippet has.
169 *
170 * @since 2.8.0
171 *
172 * @param array{
173 * raw_title?: string,
174 * raw_description?: string,
175 * effective_title?: string,
176 * effective_description?: string,
177 * focus_keyword?: string,
178 * noindex?: bool,
179 * duplicate_title?: bool,
180 * duplicate_description?: bool
181 * } $row Already-loaded snippet values.
182 * @return string[] Issue slugs, in {@see self::all()} order.
183 */
184 public static function evaluate(array $row): array {
185 $issues = [];
186
187 if ('' === trim((string) ($row['raw_title'] ?? ''))) {
188 $issues[] = self::EMPTY_TITLE;
189 }
190
191 if ('' === trim((string) ($row['raw_description'] ?? ''))) {
192 $issues[] = self::EMPTY_DESCRIPTION;
193 }
194
195 // Lengths are what Google sees, so they are measured on the effective
196 // value — a post inheriting a 90-character template title has a title
197 // that is too long even though it has no title of its own. A value
198 // that renders to nothing is reported as empty above, not as short.
199 // Measured decoded, as the editor's counter measures it: a texturized
200 // `&#038;` is one character on the results page, not six.
201 $title_length = mb_strlen(trim(Seo_Text::as_displayed((string) ($row['effective_title'] ?? ''))));
202 if ($title_length > 0 && $title_length < SEOScoreCalculator::TITLE_OPTIMAL_MIN) {
203 $issues[] = self::TITLE_TOO_SHORT;
204 } elseif ($title_length > SEOScoreCalculator::TITLE_OPTIMAL_MAX) {
205 $issues[] = self::TITLE_TOO_LONG;
206 }
207
208 $description_length = mb_strlen(trim(Seo_Text::as_displayed((string) ($row['effective_description'] ?? ''))));
209 if ($description_length > 0 && $description_length < SEOScoreCalculator::DESCRIPTION_OPTIMAL_MIN) {
210 $issues[] = self::DESCRIPTION_TOO_SHORT;
211 } elseif ($description_length > SEOScoreCalculator::DESCRIPTION_OPTIMAL_MAX) {
212 $issues[] = self::DESCRIPTION_TOO_LONG;
213 }
214
215 if ('' === trim((string) ($row['focus_keyword'] ?? ''))) {
216 $issues[] = self::NO_FOCUS_KEYWORD;
217 }
218
219 if (!empty($row['noindex'])) {
220 $issues[] = self::NOINDEX;
221 }
222
223 if (!empty($row['duplicate_title'])) {
224 $issues[] = self::DUPLICATE_TITLE;
225 }
226
227 if (!empty($row['duplicate_description'])) {
228 $issues[] = self::DUPLICATE_DESCRIPTION;
229 }
230
231 return $issues;
232 }
233
234 /**
235 * The key two posts share when they render the same title.
236 *
237 * This is the *rendered* title, normalized. Until 2.10.0 it was a hash of
238 * the inputs that produce a title instead — the stored custom title, or
239 * the post's own title when the post type's template was going to supply
240 * the rest. That was sound only while duplicates were grouped inside one
241 * post type, which is what Bulk Snippets did:
242 *
243 * - A post and a page can share a post title and still render different
244 * titles, because each post type has its own template. Grouping on the
245 * post title alone reports them as duplicates when they are not (#564).
246 * - A template carrying any tag that varies per post beyond `%title%` —
247 * `%category%`, `%date%`, `%author%` — breaks the same assumption inside
248 * a single post type, since two posts with one post title between them
249 * render different titles.
250 *
251 * Both disappear when the key is what the page actually renders, and the
252 * reverse direction improves too: a hand-written title that happens to
253 * match what another page's template renders is now matched, where the old
254 * key could not see it.
255 *
256 * There is no cost to resolving, because the caller has already resolved
257 * it: {@see self::snapshot()} computes `effective_title` for the length
258 * rules, and the index builds both keys from that one snapshot.
259 *
260 * @since 2.8.0
261 * @since 2.10.0 Keyed on the rendered title rather than on its inputs.
262 *
263 * @param string $effective_title The title the page renders.
264 * @return string Grouping key, or '' when there is nothing to compare.
265 */
266 public static function duplicate_key(string $effective_title): string {
267 $normalized = self::normalize_for_comparison($effective_title);
268
269 return '' !== $normalized ? 'title:' . $normalized : '';
270 }
271
272 /**
273 * The key two posts share when they render the same meta description.
274 *
275 * The description side has never had an input-comparison option: the
276 * default template is `%excerpt%`, which differs for every post and is not
277 * recoverable from a short stored string, so two posts inheriting the same
278 * template collide only when their excerpts do. The rendered value is the
279 * only thing worth comparing — which is now also true of the title, so the
280 * two keys are built the same way.
281 *
282 * A description that renders to nothing returns '' and never groups —
283 * "every post without a description" is the empty-description issue, not a
284 * duplicate.
285 *
286 * @since 2.10.0
287 *
288 * @param string $effective_description The description the page renders.
289 * @return string Grouping key, or '' when there is nothing to compare.
290 */
291 public static function description_duplicate_key(string $effective_description): string {
292 $normalized = self::normalize_for_comparison($effective_description);
293
294 return '' !== $normalized ? 'description:' . $normalized : '';
295 }
296
297 /**
298 * Reduce a rendered value to what a search engine would see as the same
299 * string: entities decoded, case folded, and runs of whitespace collapsed
300 * to one space.
301 *
302 * The whitespace half matters more than it looks. An excerpt rebuilt from
303 * post content can differ from a hand-written copy of it by a line break
304 * alone, and the two snippets are identical on a results page.
305 *
306 * Decoding comes first. A template title reaches here through
307 * get_the_title(), which texturizes "Foo & Bar" into `Foo &#038; Bar`,
308 * while the same words typed as an SEO title arrive as `Foo &amp; Bar` or
309 * a bare `&`. All three print the same `<title>`, and keying the encoded
310 * forms kept them in separate groups. A decoded `&nbsp;` is a no-break
311 * space, which the `/u` whitespace class then collapses like any other.
312 * Changing this changes every stored key, which is why
313 * {@see Snippet_Index::KEY_FORMAT} moved with it.
314 *
315 * @since 2.10.0
316 *
317 * @param string $value Rendered title or description.
318 * @return string Normalized value, '' when it renders to nothing.
319 */
320 private static function normalize_for_comparison(string $value): string {
321 return mb_strtolower(trim((string) preg_replace('/\s+/u', ' ', Seo_Text::as_displayed($value))));
322 }
323
324 /**
325 * Mark which rows share a duplicate key with another row.
326 *
327 * @since 2.8.0
328 *
329 * @param array<int,string> $keys Post ID => {@see self::duplicate_key()}.
330 * @return array<int,bool> Post ID => whether another post shares its key.
331 */
332 public static function duplicates(array $keys): array {
333 $counts = array_count_values(array_filter($keys, static fn ($key) => '' !== $key));
334
335 $result = [];
336 foreach ($keys as $post_id => $key) {
337 $result[$post_id] = '' !== $key && ($counts[$key] ?? 0) > 1;
338 }
339
340 return $result;
341 }
342
343 /**
344 * Whether a post is noindexed, and which layer decided it.
345 *
346 * Mirrors the cascade the frontend applies when it renders the robots tag
347 * for a singular post ({@see \ThinkRank\Frontend\SEO_Manager}): site-wide
348 * robots settings, then the post type's robots settings when switched on,
349 * then the post's own override when switched on. The last layer that sets
350 * the flag wins, so a post can be indexable inside a noindexed post type.
351 *
352 * @since 2.8.0
353 *
354 * @param int $post_id Post ID.
355 * @param string $post_type Post type.
356 * @return array{noindex: bool, source: string} source is 'post', 'post_type', 'site' or ''.
357 */
358 public static function robots_state(int $post_id, string $post_type): array {
359 $site = get_option('thinkrank_global_robot_meta_settings', []);
360 $noindex = is_array($site) && !empty($site['noindex']);
361 $source = $noindex ? 'site' : '';
362
363 $global_seo = get_option('thinkrank_global_seo_settings', []);
364 $type_settings = is_array($global_seo) ? ($global_seo[$post_type] ?? null) : null;
365 if (is_array($type_settings)
366 && !empty($type_settings['robots_meta_enabled'])
367 && is_array($type_settings['robots_meta'] ?? null)
368 && array_key_exists('noindex', $type_settings['robots_meta'])
369 ) {
370 $noindex = !empty($type_settings['robots_meta']['noindex']);
371 $source = $noindex ? 'post_type' : '';
372 }
373
374 if ((bool) get_post_meta($post_id, '_thinkrank_robots_meta_enabled', true)) {
375 $raw = (string) get_post_meta($post_id, '_thinkrank_robots_meta', true);
376 $post_robots = '' !== $raw ? json_decode($raw, true) : null;
377 if (is_array($post_robots) && array_key_exists('noindex', $post_robots)) {
378 $noindex = !empty($post_robots['noindex']);
379 $source = $noindex ? 'post' : '';
380 }
381 }
382
383 return ['noindex' => $noindex, 'source' => $source];
384 }
385 }
386