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-thin-content.php

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

449 lines 15.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Sitewide thin content 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 use ThinkRank\Core\Plan_Config;
14
15 // Prevent direct access
16 if (!defined('ABSPATH')) {
17 exit;
18 }
19
20 /**
21 * Thin Content
22 *
23 * Answers "which of my pages are thin?" across the whole site, grouped by post
24 * type, with a threshold each (#565). ThinkRank already scores word count for
25 * the post being edited; nothing told anyone which of five hundred pages to
26 * open.
27 *
28 * Four decisions shape this class.
29 *
30 * **It counts what the visitor reads.** Every count goes through
31 * {@see Word_Count_Index}, which resolves builder content, so an Elementor or
32 * Bricks page reports its real length rather than the empty `post_content`
33 * column. This is the whole reason the work has to be batched, and it is also
34 * what makes the answer different from the one the Site SEO Analyzer's depth
35 * check gives today.
36 *
37 * **It is not part of the Site SEO Analyzer.** That check reads the most recent
38 * 100 posts and reads them raw, which is right for a crawl-free sampled audit
39 * and wrong for this question: the thin pages on a large site are rarely the
40 * hundred most recent, and a builder page is not thin just because its
41 * `post_content` is empty.
42 *
43 * **A threshold per post type.** A 200-word product page is not a 200-word blog
44 * post. There is one default and an override per post type, because inventing a
45 * number for every post type on the site would be guessing with the user's
46 * content — but showing a store "products: 15 under 300" next to the control
47 * that changes 300 is not.
48 *
49 * **Published only, and on demand.** A draft nobody can read is not thin, it is
50 * unfinished. The scan is driven by the screen asking, a bounded batch at a
51 * time; scheduled alerts on newly-thin content are the Pro half, which is why
52 * the capability is read here rather than assumed.
53 *
54 * @since 2.10.0
55 */
56 class Thin_Content {
57
58 /**
59 * Option holding the thresholds.
60 */
61 public const SETTINGS_OPTION = 'thinkrank_thin_content_settings';
62
63 /**
64 * Option holding the cached report.
65 */
66 private const CACHE_OPTION = 'thinkrank_thin_content_report';
67
68 /**
69 * Statuses the report covers.
70 *
71 * @var string[]
72 */
73 private const STATUSES = ['publish'];
74
75 /**
76 * Default threshold, in the locale's counting unit.
77 *
78 * 300 is the number the Site SEO Analyzer's depth check already uses, so
79 * the two screens cannot disagree about what "thin" means.
80 */
81 public const DEFAULT_THRESHOLD = 300;
82
83 /**
84 * Bounds on a stored threshold. Zero would mark nothing thin and make the
85 * report look broken; the ceiling stops a typo turning every page red.
86 */
87 public const MIN_THRESHOLD = 1;
88 public const MAX_THRESHOLD = 10000;
89
90 /**
91 * Posts listed per post type. The counts above the list are exact; this
92 * only bounds how many are named.
93 */
94 public const MAX_LISTED = 25;
95
96 /**
97 * The report, from cache when nothing it depends on has moved.
98 *
99 * @param bool $refresh Rebuild even when the cache is current.
100 * @return array<string,mixed>
101 */
102 public static function report(bool $refresh = false): array {
103 $thresholds = self::thresholds();
104
105 // Keep filling the index before answering. Each call is bounded, and
106 // `pending` tells the caller whether the answer is complete yet.
107 $pending = Word_Count_Index::refresh(array_keys($thresholds), self::STATUSES);
108
109 // What the answer depends on. The revision covers a recount, the
110 // thresholds cover the user changing what thin means, and the unit
111 // covers a locale switch, which changes every number at once.
112 $signature = [
113 'revision' => Word_Count_Index::revision(),
114 'thresholds' => $thresholds,
115 'unit' => Word_Count_Index::unit(),
116 ];
117
118 $cached = get_option(self::CACHE_OPTION, null);
119 if (!$refresh
120 && is_array($cached)
121 && 0 === $pending
122 && ($cached['signature'] ?? null) === $signature
123 ) {
124 return self::present($cached, 0);
125 }
126
127 $totals = Word_Count_Index::totals($thresholds, self::STATUSES);
128
129 $types = [];
130 foreach ($thresholds as $post_type => $threshold) {
131 $counted = (int) ($totals[$post_type]['counted'] ?? 0);
132 $thin = (int) ($totals[$post_type]['thin'] ?? 0);
133
134 // A post type with nothing published is not a finding. Listing it
135 // would fill the report with rows that can never change.
136 if (0 === $counted) {
137 continue;
138 }
139
140 $types[] = [
141 'post_type' => $post_type,
142 'label' => self::post_type_label($post_type),
143 'threshold' => $threshold,
144 'counted' => $counted,
145 'thin' => $thin,
146 'overridden' => self::has_override($post_type),
147 'posts' => $thin > 0
148 ? self::describe(Word_Count_Index::thinnest(
149 $post_type,
150 $threshold,
151 self::STATUSES,
152 self::MAX_LISTED
153 ))
154 : [],
155 ];
156 }
157
158 $report = [
159 'signature' => $signature,
160 'types' => $types,
161 'unit' => $signature['unit'],
162 'built_at' => time(),
163 ];
164
165 // Only a complete scan is worth caching: a partial one would be served
166 // as the answer long after the index finished filling.
167 if (0 === $pending) {
168 update_option(self::CACHE_OPTION, $report, false);
169 }
170
171 return self::present($report, $pending);
172 }
173
174 /**
175 * Shape a stored report for its caller.
176 *
177 * @param array<string,mixed> $report Stored report.
178 * @param int $pending Posts still to count.
179 * @return array<string,mixed>
180 */
181 private static function present(array $report, int $pending): array {
182 $types = self::add_viewer_fields(array_values((array) ($report['types'] ?? [])));
183
184 $thin = 0;
185 $counted = 0;
186 foreach ($types as $type) {
187 $thin += (int) ($type['thin'] ?? 0);
188 $counted += (int) ($type['counted'] ?? 0);
189 }
190
191 return [
192 // Above zero means the scan has not covered the whole site yet, so
193 // the numbers below are a floor rather than the answer.
194 'pending' => max(0, $pending),
195 'counted' => $counted,
196 'thin' => $thin,
197 'types' => $types,
198 // Words on most sites, characters on ja/th/zh_* — the number above
199 // means nothing without it.
200 'unit' => (string) ($report['unit'] ?? Word_Count_Index::unit()),
201 'default_threshold' => self::default_threshold(),
202 'generated_at' => (int) ($report['built_at'] ?? 0),
203 'scheduled' => Plan_Config::can('scheduled_alerts', 'thin_content'),
204 'limits' => [
205 'max_listed' => self::MAX_LISTED,
206 'min_threshold' => self::MIN_THRESHOLD,
207 'max_threshold' => self::MAX_THRESHOLD,
208 ],
209 ];
210 }
211
212 /**
213 * Turn counted post IDs into something a reader can act on.
214 *
215 * Deliberately carries nothing that depends on *who is asking*. This array
216 * is what gets stored in the cache option, and the report is one option
217 * shared by every user: an editor's `edit_url` and `can_edit` frozen into it
218 * would be handed to the administrator who asked next, who would then be
219 * sent to the public permalink instead of the editor. {@see add_viewer_fields()}
220 * resolves those two per request instead.
221 *
222 * @param array<int,array{post_id:int, count:int}> $rows From the index.
223 * @return array<int,array<string,mixed>>
224 */
225 private static function describe(array $rows): array {
226 $ids = array_column($rows, 'post_id');
227 if (!empty($ids)) {
228 _prime_post_caches($ids, false, true);
229 }
230
231 $described = [];
232 foreach ($rows as $row) {
233 $post = get_post($row['post_id']);
234 if (!$post instanceof \WP_Post) {
235 continue;
236 }
237
238 $described[] = [
239 'post_id' => (int) $post->ID,
240 'post_title' => html_entity_decode(get_the_title($post), ENT_QUOTES, 'UTF-8'),
241 'count' => $row['count'],
242 'permalink' => (string) get_permalink($post),
243 'modified' => (string) $post->post_modified_gmt,
244 ];
245 }
246
247 return $described;
248 }
249
250 /**
251 * Add the two fields that belong to the reader rather than to the report.
252 *
253 * Answered for the current user on every request, cache hit or not, because
254 * the cached report is shared and capabilities are not.
255 *
256 * @param array<int,array<string,mixed>> $types Post type rows.
257 * @return array<int,array<string,mixed>>
258 */
259 private static function add_viewer_fields(array $types): array {
260 $ids = [];
261 foreach ($types as $type) {
262 foreach ((array) ($type['posts'] ?? []) as $post) {
263 $ids[] = (int) ($post['post_id'] ?? 0);
264 }
265 }
266
267 $ids = array_values(array_filter($ids));
268 if (!empty($ids)) {
269 _prime_post_caches($ids, false, true);
270 }
271
272 foreach ($types as $i => $type) {
273 foreach ((array) ($type['posts'] ?? []) as $j => $post) {
274 $post_id = (int) ($post['post_id'] ?? 0);
275
276 $types[$i]['posts'][$j]['can_edit'] = $post_id > 0
277 && current_user_can('edit_post', $post_id);
278 $types[$i]['posts'][$j]['edit_url'] = $post_id > 0
279 ? (string) get_edit_post_link($post_id, 'raw')
280 : '';
281 }
282 }
283
284 return $types;
285 }
286
287 /**
288 * The threshold for every post type in scope.
289 *
290 * @return array<string,int> Post type => threshold, in post type order.
291 */
292 public static function thresholds(): array {
293 $settings = self::settings();
294
295 $thresholds = [];
296 foreach (self::post_types() as $post_type) {
297 $thresholds[$post_type] = isset($settings['overrides'][$post_type])
298 ? self::clamp((int) $settings['overrides'][$post_type])
299 : (int) $settings['default'];
300 }
301
302 return $thresholds;
303 }
304
305 /**
306 * The default threshold, applied to any post type with no override.
307 *
308 * @return int
309 */
310 public static function default_threshold(): int {
311 return (int) self::settings()['default'];
312 }
313
314 /**
315 * Whether a post type has a threshold of its own.
316 *
317 * @param string $post_type Post type.
318 * @return bool
319 */
320 private static function has_override(string $post_type): bool {
321 return isset(self::settings()['overrides'][$post_type]);
322 }
323
324 /**
325 * Stored settings, with every value validated.
326 *
327 * @return array{default:int, overrides:array<string,int>}
328 */
329 public static function settings(): array {
330 $stored = get_option(self::SETTINGS_OPTION, []);
331 $stored = is_array($stored) ? $stored : [];
332
333 $default = isset($stored['default'])
334 ? self::clamp((int) $stored['default'])
335 : self::DEFAULT_THRESHOLD;
336
337 $overrides = [];
338 $raw = isset($stored['overrides']) && is_array($stored['overrides']) ? $stored['overrides'] : [];
339 $allowed = self::post_types();
340 foreach ($raw as $post_type => $value) {
341 $post_type = (string) $post_type;
342
343 // A post type that has been unregistered since the override was
344 // saved is kept out of the map rather than dropped from storage:
345 // deactivating a plugin for an afternoon should not lose the
346 // threshold its post type had.
347 if (!in_array($post_type, $allowed, true)) {
348 continue;
349 }
350
351 $overrides[$post_type] = self::clamp((int) $value);
352 }
353
354 return ['default' => $default, 'overrides' => $overrides];
355 }
356
357 /**
358 * Store new settings, merging into what is there.
359 *
360 * @param array<string,mixed> $input Partial settings.
361 * @return array{default:int, overrides:array<string,int>} What is now stored.
362 */
363 public static function save_settings(array $input): array {
364 $stored = get_option(self::SETTINGS_OPTION, []);
365 $stored = is_array($stored) ? $stored : [];
366
367 $next = [
368 'default' => isset($stored['default']) ? (int) $stored['default'] : self::DEFAULT_THRESHOLD,
369 'overrides' => isset($stored['overrides']) && is_array($stored['overrides']) ? $stored['overrides'] : [],
370 ];
371
372 if (isset($input['default'])) {
373 $next['default'] = self::clamp((int) $input['default']);
374 }
375
376 if (isset($input['overrides']) && is_array($input['overrides'])) {
377 foreach ($input['overrides'] as $post_type => $value) {
378 $post_type = sanitize_key((string) $post_type);
379
380 // null clears an override and returns the post type to the
381 // default, which is the only way back from one.
382 if (null === $value || '' === $value) {
383 unset($next['overrides'][$post_type]);
384 continue;
385 }
386
387 $next['overrides'][$post_type] = self::clamp((int) $value);
388 }
389 }
390
391 update_option(self::SETTINGS_OPTION, $next, false);
392
393 // The thresholds are part of the report's signature, so the cached
394 // report retires itself. Nothing has to be recounted: a threshold
395 // decides how a count is read, not what it is.
396 return self::settings();
397 }
398
399 /**
400 * Hold a threshold inside its bounds.
401 *
402 * @param int $value Requested threshold.
403 * @return int
404 */
405 public static function clamp(int $value): int {
406 return max(self::MIN_THRESHOLD, min(self::MAX_THRESHOLD, $value));
407 }
408
409 /**
410 * Post types the report covers.
411 *
412 * The same policy the rest of Global SEO applies, resolved here rather than
413 * on {@see Global_SEO_Post_Types} so this feature carries no shared-surface
414 * change of its own. Attachments are excluded: an attachment page has no
415 * body to be thin, and including them would report every image on the site.
416 *
417 * @return string[]
418 */
419 public static function post_types(): array {
420 $post_types = [];
421
422 foreach (get_post_types(['public' => true], 'objects') as $object) {
423 if ('attachment' === $object->name || !Global_SEO_Post_Types::is_allowed($object)) {
424 continue;
425 }
426
427 $post_types[] = $object->name;
428 }
429
430 return $post_types;
431 }
432
433 /**
434 * A post type's plural name, for the report.
435 *
436 * @param string $post_type Post type.
437 * @return string
438 */
439 private static function post_type_label(string $post_type): string {
440 $object = get_post_type_object($post_type);
441
442 if (!$object instanceof \WP_Post_Type) {
443 return $post_type;
444 }
445
446 return (string) ($object->labels->name ?? $object->label ?? $post_type);
447 }
448 }
449