PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.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 1.0.2 All 50 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.9.0, at includes/seo/class-snippet-issues.php

304 lines 10.8 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
15 // Prevent direct access
16 if (!defined('ABSPATH')) {
17 exit;
18 }
19
20 /**
21 * Snippet Issues
22 *
23 * Decides which problems a post's search snippet has — the flags behind Bulk
24 * Snippets' "show only posts with a problem" filter (#727).
25 *
26 * Two definitions matter and are easy to get wrong:
27 *
28 * - **Empty** means no value of the post's own. An empty
29 * `_thinkrank_seo_title` is not "no title": the post inherits its post type's
30 * template, and that rendered title is what Google sees. So the empty flags
31 * answer "nobody wrote one for this post", and the length flags are judged
32 * on the *effective* value — the same value the editor score judges, via
33 * {@see Pattern_Resolver::effective_title()}.
34 * - **Length** bands are the editor's, read from {@see SEOScoreCalculator}, so
35 * a post this screen calls "too long" is one the editor also calls too long.
36 *
37 * The rule evaluation is a pure function of an already-loaded row, so it is
38 * testable without WordPress and cheap to run over a whole post type.
39 *
40 * @since 2.8.0
41 */
42 class Snippet_Issues {
43
44 public const EMPTY_TITLE = 'empty_title';
45 public const EMPTY_DESCRIPTION = 'empty_description';
46 public const TITLE_TOO_SHORT = 'title_too_short';
47 public const TITLE_TOO_LONG = 'title_too_long';
48 public const DESCRIPTION_TOO_SHORT = 'description_too_short';
49 public const DESCRIPTION_TOO_LONG = 'description_too_long';
50 public const NO_FOCUS_KEYWORD = 'no_focus_keyword';
51 public const NOINDEX = 'noindex';
52 public const DUPLICATE_TITLE = 'duplicate_title';
53
54 /**
55 * Every issue, in the order the filter chips show them.
56 *
57 * @since 2.8.0
58 *
59 * @return string[]
60 */
61 public static function all(): array {
62 return [
63 self::EMPTY_TITLE,
64 self::EMPTY_DESCRIPTION,
65 self::TITLE_TOO_SHORT,
66 self::TITLE_TOO_LONG,
67 self::DESCRIPTION_TOO_SHORT,
68 self::DESCRIPTION_TOO_LONG,
69 self::NO_FOCUS_KEYWORD,
70 self::NOINDEX,
71 self::DUPLICATE_TITLE,
72 ];
73 }
74
75 /**
76 * Bit per issue that depends on the post alone.
77 *
78 * Duplicate title is not here: it depends on the other posts, so the index
79 * stores a key for it and finds duplicates by grouping at query time. The
80 * order is part of the stored format — append, never reorder.
81 *
82 * @since 2.8.0
83 *
84 * @return array<string,int> Issue => bit.
85 */
86 public static function flag_bits(): array {
87 $bits = [];
88 $position = 0;
89 foreach (self::all() as $issue) {
90 if (self::DUPLICATE_TITLE === $issue) {
91 continue;
92 }
93 $bits[$issue] = 1 << $position;
94 $position++;
95 }
96
97 return $bits;
98 }
99
100 /**
101 * Encode issues as a bitmask. Duplicate title is ignored (see flag_bits()).
102 *
103 * @since 2.8.0
104 *
105 * @param string[] $issues Issue slugs.
106 * @return int
107 */
108 public static function to_flags(array $issues): int {
109 $bits = self::flag_bits();
110 $flags = 0;
111 foreach ($issues as $issue) {
112 $flags |= $bits[$issue] ?? 0;
113 }
114
115 return $flags;
116 }
117
118 /**
119 * Load everything the rules need for one post.
120 *
121 * Duplicate status is not included — it needs the rest of the post type.
122 *
123 * @since 2.8.0
124 *
125 * @param \WP_Post $post Post.
126 * @return array<string,mixed>
127 */
128 public static function snapshot(\WP_Post $post): array {
129 $post_id = (int) $post->ID;
130 $keywords = Focus_Keywords::get($post_id);
131 $robots = self::robots_state($post_id, $post->post_type);
132
133 return [
134 'post' => $post,
135 'raw_title' => (string) get_post_meta($post_id, '_thinkrank_seo_title', true),
136 'raw_description' => (string) get_post_meta($post_id, '_thinkrank_meta_description', true),
137 'effective_title' => Pattern_Resolver::effective_title($post_id),
138 'effective_description' => Pattern_Resolver::effective_description($post_id),
139 'focus_keyword' => (string) ($keywords[0] ?? ''),
140 'noindex' => $robots['noindex'],
141 'noindex_source' => $robots['source'],
142 ];
143 }
144
145 /**
146 * Which issues a snippet has.
147 *
148 * @since 2.8.0
149 *
150 * @param array{
151 * raw_title?: string,
152 * raw_description?: string,
153 * effective_title?: string,
154 * effective_description?: string,
155 * focus_keyword?: string,
156 * noindex?: bool,
157 * duplicate_title?: bool
158 * } $row Already-loaded snippet values.
159 * @return string[] Issue slugs, in {@see self::all()} order.
160 */
161 public static function evaluate(array $row): array {
162 $issues = [];
163
164 if ('' === trim((string) ($row['raw_title'] ?? ''))) {
165 $issues[] = self::EMPTY_TITLE;
166 }
167
168 if ('' === trim((string) ($row['raw_description'] ?? ''))) {
169 $issues[] = self::EMPTY_DESCRIPTION;
170 }
171
172 // Lengths are what Google sees, so they are measured on the effective
173 // value — a post inheriting a 90-character template title has a title
174 // that is too long even though it has no title of its own. A value
175 // that renders to nothing is reported as empty above, not as short.
176 $title_length = mb_strlen(trim((string) ($row['effective_title'] ?? '')));
177 if ($title_length > 0 && $title_length < SEOScoreCalculator::TITLE_OPTIMAL_MIN) {
178 $issues[] = self::TITLE_TOO_SHORT;
179 } elseif ($title_length > SEOScoreCalculator::TITLE_OPTIMAL_MAX) {
180 $issues[] = self::TITLE_TOO_LONG;
181 }
182
183 $description_length = mb_strlen(trim((string) ($row['effective_description'] ?? '')));
184 if ($description_length > 0 && $description_length < SEOScoreCalculator::DESCRIPTION_OPTIMAL_MIN) {
185 $issues[] = self::DESCRIPTION_TOO_SHORT;
186 } elseif ($description_length > SEOScoreCalculator::DESCRIPTION_OPTIMAL_MAX) {
187 $issues[] = self::DESCRIPTION_TOO_LONG;
188 }
189
190 if ('' === trim((string) ($row['focus_keyword'] ?? ''))) {
191 $issues[] = self::NO_FOCUS_KEYWORD;
192 }
193
194 if (!empty($row['noindex'])) {
195 $issues[] = self::NOINDEX;
196 }
197
198 if (!empty($row['duplicate_title'])) {
199 $issues[] = self::DUPLICATE_TITLE;
200 }
201
202 return $issues;
203 }
204
205 /**
206 * The key two posts share when they render the same title.
207 *
208 * Duplicate detection has to work without resolving every title on the
209 * site, so it compares the *inputs* that produce a title rather than the
210 * output, case-insensitively (a search engine does not care about case):
211 *
212 * - a custom title with no variable tags is the title itself;
213 * - a custom title with tags renders from the tags plus the post's own
214 * title, so both are the key;
215 * - no custom title means the post type's template, which is the same for
216 * every post of the type, so the post's title is the key.
217 *
218 * Equal inputs give equal titles. The reverse is not guaranteed — a custom
219 * "Foo – Site" and a template that happens to render the same string are
220 * not matched — which errs toward missing a duplicate, never toward
221 * inventing one.
222 *
223 * @since 2.8.0
224 *
225 * @param string $raw_title Stored `_thinkrank_seo_title` (may be empty).
226 * @param string $post_title The post's own title.
227 * @return string Grouping key, or '' when there is nothing to compare.
228 */
229 public static function duplicate_key(string $raw_title, string $post_title): string {
230 $raw_title = mb_strtolower(trim($raw_title));
231 $post_title = mb_strtolower(trim($post_title));
232
233 if ('' !== $raw_title) {
234 return false === strpos($raw_title, '%')
235 ? 'custom:' . $raw_title
236 : 'tagged:' . $raw_title . '|' . $post_title;
237 }
238
239 return '' !== $post_title ? 'template:' . $post_title : '';
240 }
241
242 /**
243 * Mark which rows share a duplicate key with another row.
244 *
245 * @since 2.8.0
246 *
247 * @param array<int,string> $keys Post ID => {@see self::duplicate_key()}.
248 * @return array<int,bool> Post ID => whether another post shares its key.
249 */
250 public static function duplicates(array $keys): array {
251 $counts = array_count_values(array_filter($keys, static fn ($key) => '' !== $key));
252
253 $result = [];
254 foreach ($keys as $post_id => $key) {
255 $result[$post_id] = '' !== $key && ($counts[$key] ?? 0) > 1;
256 }
257
258 return $result;
259 }
260
261 /**
262 * Whether a post is noindexed, and which layer decided it.
263 *
264 * Mirrors the cascade the frontend applies when it renders the robots tag
265 * for a singular post ({@see \ThinkRank\Frontend\SEO_Manager}): site-wide
266 * robots settings, then the post type's robots settings when switched on,
267 * then the post's own override when switched on. The last layer that sets
268 * the flag wins, so a post can be indexable inside a noindexed post type.
269 *
270 * @since 2.8.0
271 *
272 * @param int $post_id Post ID.
273 * @param string $post_type Post type.
274 * @return array{noindex: bool, source: string} source is 'post', 'post_type', 'site' or ''.
275 */
276 public static function robots_state(int $post_id, string $post_type): array {
277 $site = get_option('thinkrank_global_robot_meta_settings', []);
278 $noindex = is_array($site) && !empty($site['noindex']);
279 $source = $noindex ? 'site' : '';
280
281 $global_seo = get_option('thinkrank_global_seo_settings', []);
282 $type_settings = is_array($global_seo) ? ($global_seo[$post_type] ?? null) : null;
283 if (is_array($type_settings)
284 && !empty($type_settings['robots_meta_enabled'])
285 && is_array($type_settings['robots_meta'] ?? null)
286 && array_key_exists('noindex', $type_settings['robots_meta'])
287 ) {
288 $noindex = !empty($type_settings['robots_meta']['noindex']);
289 $source = $noindex ? 'post_type' : '';
290 }
291
292 if ((bool) get_post_meta($post_id, '_thinkrank_robots_meta_enabled', true)) {
293 $raw = (string) get_post_meta($post_id, '_thinkrank_robots_meta', true);
294 $post_robots = '' !== $raw ? json_decode($raw, true) : null;
295 if (is_array($post_robots) && array_key_exists('noindex', $post_robots)) {
296 $noindex = !empty($post_robots['noindex']);
297 $source = $noindex ? 'post' : '';
298 }
299 }
300
301 return ['noindex' => $noindex, 'source' => $source];
302 }
303 }
304