PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.1
2.14.1 2.14.0 2.13.0 2.12.0 2.11.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 All 56 releases
thinkrank / includes / seo / class-indexability.php

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

332 lines 10.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Indexability
4 *
5 * @package ThinkRank
6 * @subpackage SEO
7 * @since 2.15.0
8 */
9
10 declare(strict_types=1);
11
12 namespace ThinkRank\SEO;
13
14 // Prevent direct access.
15 if (!defined('ABSPATH')) {
16 exit;
17 }
18
19 /**
20 * Whether a post or term is a destination worth submitting to a search engine.
21 *
22 * The robots tag a page prints was the only place that decided indexability.
23 * Every URL feed re-decided it partially or not at all: the sitemap honoured a
24 * per-post noindex but not a noindexed post type or taxonomy, and listed URLs
25 * ThinkRank itself redirects; IndexNow checked nothing beyond the post type
26 * (#911). Each of those contradicts a signal the site sends elsewhere, which
27 * Search Console reports as "Submitted URL marked noindex" and "Page with
28 * redirect".
29 *
30 * So this class answers the question once, from the same inputs the robots tag
31 * reads, and every feed asks it. It is public so ThinkRank Pro's feeds (news
32 * and video sitemaps, llms-full.txt, internal-link targets) can use it too.
33 *
34 * Three things make an object a non-destination:
35 * - the effective robots decision is noindex, resolved through the cascade
36 * {@see \ThinkRank\Frontend\SEO_Manager} uses for the robots tag: site-wide
37 * directives, then the content type's row in the matrix, then the object's
38 * own override, each merged over the last;
39 * - the post has a password;
40 * - the object redirects elsewhere, either through its own "Redirect this to"
41 * field ({@see Object_Redirect}) or because a redirect provider answers the
42 * `thinkrank_url_redirects` filter for its URL.
43 *
44 * @since 2.15.0
45 */
46 final class Indexability {
47
48 /**
49 * Site-wide robots directives (the base of the cascade).
50 */
51 private const GLOBAL_ROBOTS_OPTION = 'thinkrank_global_robot_meta_settings';
52
53 /**
54 * Robots keys a per-object override may set, as the robots tag reads them.
55 */
56 private const OVERRIDE_KEYS = ['index', 'noindex', 'nofollow', 'noarchive', 'noimageindex', 'nosnippet'];
57
58 /**
59 * Reasons an object is not indexable.
60 */
61 public const REASON_NOINDEX = 'noindex';
62 public const REASON_PASSWORD = 'password';
63 public const REASON_REDIRECT = 'redirect';
64
65 /**
66 * Whether a post or term may be submitted to search engines.
67 *
68 * @since 2.15.0
69 *
70 * @param \WP_Post|\WP_Term $item Post or term.
71 * @return bool
72 */
73 public static function is_indexable_destination($item): bool {
74 if ($item instanceof \WP_Post) {
75 return self::is_indexable_post($item);
76 }
77
78 if ($item instanceof \WP_Term) {
79 return self::is_indexable_term($item);
80 }
81
82 return false;
83 }
84
85 /**
86 * Whether a post may be submitted to search engines.
87 *
88 * @since 2.15.0
89 *
90 * @param \WP_Post $post Post.
91 * @return bool
92 */
93 public static function is_indexable_post(\WP_Post $post): bool {
94 return self::post_exclusion_reason($post) === null;
95 }
96
97 /**
98 * Whether a term archive may be submitted to search engines.
99 *
100 * @since 2.15.0
101 *
102 * @param \WP_Term $term Term.
103 * @return bool
104 */
105 public static function is_indexable_term(\WP_Term $term): bool {
106 return self::term_exclusion_reason($term) === null;
107 }
108
109 /**
110 * Why a post is not indexable, or null when it is.
111 *
112 * Cheapest check first: the password is on the object, the robots cascade
113 * reads options and primed meta, and a redirect lookup may query the
114 * redirect provider's table.
115 *
116 * @since 2.15.0
117 *
118 * @param \WP_Post $post Post.
119 * @return string|null One of the REASON_* constants, or null.
120 */
121 public static function post_exclusion_reason(\WP_Post $post): ?string {
122 if (self::is_password_protected($post)) {
123 return self::REASON_PASSWORD;
124 }
125
126 if (self::is_post_noindexed($post)) {
127 return self::REASON_NOINDEX;
128 }
129
130 if (self::is_post_redirected($post)) {
131 return self::REASON_REDIRECT;
132 }
133
134 return null;
135 }
136
137 /**
138 * Why a term is not indexable, or null when it is.
139 *
140 * @since 2.15.0
141 *
142 * @param \WP_Term $term Term.
143 * @return string|null One of the REASON_* constants, or null.
144 */
145 public static function term_exclusion_reason(\WP_Term $term): ?string {
146 if (self::is_term_noindexed($term)) {
147 return self::REASON_NOINDEX;
148 }
149
150 if (self::is_term_redirected($term)) {
151 return self::REASON_REDIRECT;
152 }
153
154 return null;
155 }
156
157 /**
158 * Whether the post has a password.
159 *
160 * @since 2.15.0
161 *
162 * @param \WP_Post $post Post.
163 * @return bool
164 */
165 public static function is_password_protected(\WP_Post $post): bool {
166 return (string) $post->post_password !== '';
167 }
168
169 /**
170 * Whether the robots tag on this post's page says noindex.
171 *
172 * Site-wide directives, then the post type's own (when its robots switch
173 * is on), then the post's override. A post-level `noindex: false` beats a
174 * noindexed type, exactly as it does in the tag.
175 *
176 * @since 2.15.0
177 *
178 * @param \WP_Post $post Post.
179 * @return bool
180 */
181 public static function is_post_noindexed(\WP_Post $post): bool {
182 $settings = self::merge_entity_robots(self::global_robots(), (string) $post->post_type);
183
184 $settings = self::merge_override(
185 $settings,
186 (bool) get_post_meta($post->ID, '_thinkrank_robots_meta_enabled', true),
187 get_post_meta($post->ID, '_thinkrank_robots_meta', true)
188 );
189
190 return !empty($settings['noindex']);
191 }
192
193 /**
194 * Whether the robots tag on this term's archive says noindex.
195 *
196 * Site-wide directives, then the taxonomy's matrix row, then the term's
197 * override.
198 *
199 * @since 2.15.0
200 *
201 * @param \WP_Term $term Term.
202 * @return bool
203 */
204 public static function is_term_noindexed(\WP_Term $term): bool {
205 $settings = self::merge_entity_robots(
206 self::global_robots(),
207 Content_Type_Settings::PREFIX_TAXONOMY . $term->taxonomy
208 );
209
210 $settings = self::merge_override(
211 $settings,
212 (bool) get_term_meta($term->term_id, '_thinkrank_robots_meta_enabled', true),
213 get_term_meta($term->term_id, '_thinkrank_robots_meta', true)
214 );
215
216 return !empty($settings['noindex']);
217 }
218
219 /**
220 * Whether a request for this post is redirected elsewhere.
221 *
222 * @since 2.15.0
223 *
224 * @param \WP_Post $post Post.
225 * @return bool
226 */
227 public static function is_post_redirected(\WP_Post $post): bool {
228 if (Object_Redirect::get('post', (int) $post->ID)['url'] !== '') {
229 return true;
230 }
231
232 $url = get_permalink($post);
233
234 return is_string($url) && $url !== '' && self::url_is_redirected($url, $post);
235 }
236
237 /**
238 * Whether a request for this term's archive is redirected elsewhere.
239 *
240 * @since 2.15.0
241 *
242 * @param \WP_Term $term Term.
243 * @return bool
244 */
245 public static function is_term_redirected(\WP_Term $term): bool {
246 if (Object_Redirect::get('term', (int) $term->term_id)['url'] !== '') {
247 return true;
248 }
249
250 $url = get_term_link($term);
251
252 return is_string($url) && $url !== '' && self::url_is_redirected($url, $term);
253 }
254
255 /**
256 * Ask the redirect provider whether a URL is redirected.
257 *
258 * @param string $url The object's URL.
259 * @param \WP_Post|\WP_Term $item The object it belongs to.
260 * @return bool
261 */
262 private static function url_is_redirected(string $url, $item): bool {
263 /**
264 * Filter whether a URL is redirected by a rule that is not tied to an object.
265 *
266 * Per-object redirects are already read through `thinkrank_object_redirect`.
267 * This covers the rest: a rule whose source path happens to be a post's
268 * or term's URL. Nothing answers it in the free plugin; a redirect
269 * provider such as ThinkRank Pro does, from its own rule store.
270 *
271 * @since 2.15.0
272 *
273 * @param bool $redirected Whether the URL redirects. Default false.
274 * @param string $url The post's permalink or the term's archive link.
275 * @param \WP_Post|\WP_Term $item The object the URL belongs to.
276 */
277 return (bool) apply_filters('thinkrank_url_redirects', false, $url, $item);
278 }
279
280 /**
281 * Site-wide robots directives with the same defaults the robots tag uses.
282 *
283 * @return array
284 */
285 private static function global_robots(): array {
286 $stored = get_option(self::GLOBAL_ROBOTS_OPTION, []);
287
288 return array_merge(
289 ['index' => true, 'noindex' => false],
290 is_array($stored) ? $stored : []
291 );
292 }
293
294 /**
295 * Merge a matrix row's robots directives when its robots switch is on.
296 *
297 * @param array $settings Directives so far.
298 * @param string $entity_key Content_Type_Settings entity key.
299 * @return array
300 */
301 private static function merge_entity_robots(array $settings, string $entity_key): array {
302 $entity = Content_Type_Settings::get_entity_settings($entity_key);
303
304 if (!empty($entity['robots_meta_enabled']) && is_array($entity['robots_meta'] ?? null)) {
305 $settings = array_merge($settings, $entity['robots_meta']);
306 }
307
308 return $settings;
309 }
310
311 /**
312 * Merge an object's stored robots override when it is switched on.
313 *
314 * @param array $settings Directives so far.
315 * @param bool $enabled The object's `_thinkrank_robots_meta_enabled`.
316 * @param mixed $raw The object's `_thinkrank_robots_meta` JSON.
317 * @return array
318 */
319 private static function merge_override(array $settings, bool $enabled, $raw): array {
320 if (!$enabled || !is_string($raw) || $raw === '') {
321 return $settings;
322 }
323
324 $override = json_decode($raw, true);
325 if (!is_array($override)) {
326 return $settings;
327 }
328
329 return array_merge($settings, array_intersect_key($override, array_flip(self::OVERRIDE_KEYS)));
330 }
331 }
332