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 / diagnostics / class-foreign-schema-detector.php

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

626 lines 20.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Foreign JSON-LD detector.
4 *
5 * @package ThinkRank\Diagnostics
6 */
7
8 declare(strict_types=1);
9
10 namespace ThinkRank\Diagnostics;
11
12 use ThinkRank\Core\SEO_Plugin_Detector;
13
14 if (!defined('ABSPATH')) {
15 exit;
16 }
17
18 /**
19 * Finds JSON-LD on a rendered page that ThinkRank did not emit.
20 *
21 * #355 gave ThinkRank a single arbitrated `@graph` so its own emitters stop
22 * contradicting each other on one URL. Nothing arbitrates against schema
23 * produced by *other* plugins: when a second schema-producing plugin is active
24 * both publish page-level entities for the same URL with no coordination, which
25 * is the same failure #355 closed, only sourced externally (#447).
26 *
27 * This class detects that and nothing else. It warns; it never merges ThinkRank's
28 * output into another plugin's graph and never suppresses the other plugin's
29 * output. Merging is deliberately out of scope: ThinkRank is the primary SEO
30 * plugin, and building for two active ones would endorse a configuration we do
31 * not want users in and mean tracking competitors' graph structures as they change.
32 *
33 * Detection runs on demand from Site Health (see Schema_Conflict_Health_Check),
34 * not on every pageview. Buffering the front end to scan it would put the work
35 * on every anonymous request, which is how #402 happened.
36 *
37 * @since 2.9.0
38 */
39 class Foreign_Schema_Detector {
40
41 /**
42 * Transient holding the last scan result.
43 */
44 private const CACHE_KEY = 'thinkrank_foreign_schema_scan';
45
46 /**
47 * Cache TTL in seconds (1 hour).
48 */
49 private const CACHE_TTL = HOUR_IN_SECONDS;
50
51 /**
52 * Timeout for the loopback fetch, in seconds.
53 */
54 private const FETCH_TIMEOUT = 10;
55
56 /**
57 * Signatures that attribute a foreign JSON-LD block to a named plugin.
58 *
59 * `class` matches a token in the script tag's class attribute, which is the
60 * strongest signal available — the big four each tag their own block. `comment`
61 * matches the HTML comment wrapper a plugin prints around its head output,
62 * for the ones that carry no class. `id`, where present, matches the script
63 * tag's id attribute exactly, for a plugin that labels its block that way.
64 *
65 * @var array<string, array{name:string, class:string[], comment:string[], id?:string[]}>
66 */
67 private const SIGNATURES = [
68 'yoast' => [
69 'name' => 'Yoast SEO',
70 'class' => ['yoast-schema-graph'],
71 'comment' => ['yoast seo plugin'],
72 ],
73 'rankmath' => [
74 'name' => 'Rank Math',
75 'class' => ['rank-math-schema', 'rank-math-schema-pro'],
76 'comment' => ['rank math wordpress seo'],
77 ],
78 'aioseo' => [
79 'name' => 'All in One SEO',
80 'class' => ['aioseo-schema'],
81 'comment' => ['all in one seo'],
82 ],
83 'seopress' => [
84 'name' => 'SEOPress',
85 'class' => ['seopress-schema'],
86 'comment' => ['seopress'],
87 ],
88 'theseoframework' => [
89 'name' => 'The SEO Framework',
90 'class' => [],
91 'comment' => ['the seo framework'],
92 ],
93 'slimseo' => [
94 'name' => 'Slim SEO',
95 'class' => ['slim-seo-schema'],
96 'comment' => ['slim seo'],
97 ],
98 'woocommerce' => [
99 'name' => 'WooCommerce',
100 'class' => [],
101 'comment' => ['woocommerce json-ld'],
102 ],
103 // SureRank tags its block by id, not class, and prints it outside
104 // its "SureRank Meta Data" comment pair (#916).
105 'surerank' => [
106 'name' => 'SureRank',
107 'class' => [],
108 'comment' => [],
109 'id' => ['surerank-schema'],
110 ],
111 ];
112
113 /**
114 * Schema types worth warning about when both sides publish one.
115 *
116 * A duplicated `WebPage` or `Organization` is the conflict users get
117 * penalised for. A second `SearchAction` or `ImageObject` is noise, so the
118 * notice stays about entities a search engine reconciles per URL.
119 *
120 * @var string[]
121 */
122 private const PAGE_LEVEL_TYPES = [
123 'Article',
124 'BlogPosting',
125 'NewsArticle',
126 'BreadcrumbList',
127 'CollectionPage',
128 'ContactPage',
129 'Event',
130 'FAQPage',
131 'HowTo',
132 'ItemList',
133 'LocalBusiness',
134 'Organization',
135 'Person',
136 'Product',
137 'ProfilePage',
138 'Recipe',
139 'SearchResultsPage',
140 'SoftwareApplication',
141 'VideoObject',
142 'WebPage',
143 'WebSite',
144 ];
145
146 /**
147 * Run a scan, using the cached result when one is fresh.
148 *
149 * @param bool $use_cache Whether a cached result may be returned.
150 * @return array<string, mixed> Scan report, see build_report().
151 */
152 public function scan(bool $use_cache = true): array {
153 if ($use_cache) {
154 $cached = get_transient(self::CACHE_KEY);
155 if (is_array($cached)) {
156 return $cached;
157 }
158 }
159
160 $url = $this->representative_url();
161 $html = $this->fetch($url);
162
163 if (is_wp_error($html)) {
164 $report = [
165 'scanned_url' => $url,
166 'error' => $html->get_error_message(),
167 'conflicts' => [],
168 'foreign' => [],
169 'own_types' => [],
170 'checked_at' => time(),
171 ];
172 } else {
173 $report = $this->analyze($html);
174 $report['scanned_url'] = $url;
175 $report['checked_at'] = time();
176 }
177
178 set_transient(self::CACHE_KEY, $report, self::CACHE_TTL);
179
180 return $report;
181 }
182
183 /**
184 * Drop the cached scan result.
185 *
186 * @return void
187 */
188 public static function flush_cache(): void {
189 delete_transient(self::CACHE_KEY);
190 }
191
192 /**
193 * Analyse a rendered HTML document for foreign JSON-LD.
194 *
195 * Kept separate from the fetch so it can be exercised against a fixture
196 * without an HTTP request.
197 *
198 * @param string $html Rendered page HTML.
199 * @return array{conflicts:array, foreign:array, own_types:string[]}
200 */
201 public function analyze(string $html): array {
202 $blocks = $this->extract_blocks($html);
203 $own_types = [];
204 $foreign = [];
205
206 foreach ($blocks as $block) {
207 $types = $this->collect_types($this->decode($block['json']));
208
209 if ($block['is_ours']) {
210 $own_types = array_merge($own_types, $types);
211 continue;
212 }
213
214 $source = $this->attribute($block);
215
216 if (!isset($foreign[$source['slug']])) {
217 $foreign[$source['slug']] = [
218 'slug' => $source['slug'],
219 'name' => $source['name'],
220 'guess' => $source['guess'],
221 'types' => [],
222 'blocks' => 0,
223 ];
224 }
225
226 $foreign[$source['slug']]['types'] = array_merge($foreign[$source['slug']]['types'], $types);
227 $foreign[$source['slug']]['blocks']++;
228 }
229
230 $own_types = $this->unique_types($own_types);
231 $conflicts = [];
232
233 foreach ($foreign as $slug => $entry) {
234 $entry['types'] = $this->unique_types($entry['types']);
235 $foreign[$slug] = $entry;
236
237 $duplicated = array_values(array_intersect(
238 $this->page_level_only($own_types),
239 $this->page_level_only($entry['types'])
240 ));
241
242 if (!empty($duplicated)) {
243 $conflicts[] = [
244 'slug' => $slug,
245 'name' => $entry['name'],
246 'guess' => $entry['guess'],
247 'duplicated' => $duplicated,
248 ];
249 }
250 }
251
252 return [
253 'conflicts' => $conflicts,
254 'foreign' => array_values($foreign),
255 'own_types' => $own_types,
256 ];
257 }
258
259 /**
260 * Pull every `application/ld+json` block out of a document.
261 *
262 * Regex rather than DOMDocument: the scan runs against whatever a third
263 * party emitted, and a malformed document must still yield the blocks that
264 * did parse. Each block carries the raw tag and the comment immediately
265 * preceding it, which is what attribution reads.
266 *
267 * @param string $html Rendered page HTML.
268 * @return array<int, array{json:string, tag:string, preceding:string, is_ours:bool}>
269 */
270 private function extract_blocks(string $html): array {
271 $pattern = '#<script\b([^>]*\btype\s*=\s*["\']application/ld\+json["\'][^>]*)>(.*?)</script>#is';
272
273 if (!preg_match_all($pattern, $html, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER)) {
274 return [];
275 }
276
277 $blocks = [];
278
279 foreach ($matches as $match) {
280 $attributes = $match[1][0];
281 $offset = (int) $match[0][1];
282 $preceding = $this->preceding_comment($html, $offset);
283
284 $blocks[] = [
285 'json' => $match[2][0],
286 'tag' => $attributes,
287 'preceding' => $preceding,
288 'is_ours' => $this->is_ours($attributes, $preceding),
289 ];
290 }
291
292 return $blocks;
293 }
294
295 /**
296 * The HTML comment that ends just before a script block, if any.
297 *
298 * Only whitespace may sit between the comment and the tag, so an unrelated
299 * comment further up the head is never treated as the block's label.
300 *
301 * A *closing* comment is not a label. Both ThinkRank and Yoast bracket their
302 * head output in a matched pair, so the text immediately before a block is
303 * very often the previous block's `<!-- /… -->` — reading that as the label
304 * made every plugin's schema look like ThinkRank's the moment it happened to
305 * be printed next.
306 *
307 * @param string $html Rendered page HTML.
308 * @param int $offset Byte offset of the script tag.
309 * @return string Comment body (without the delimiters), or an empty string.
310 */
311 private function preceding_comment(string $html, int $offset): string {
312 $before = rtrim(substr($html, 0, $offset));
313
314 if (substr($before, -3) !== '-->') {
315 return '';
316 }
317
318 $start = strrpos($before, '<!--');
319
320 if ($start === false) {
321 return '';
322 }
323
324 $comment = trim(substr($before, $start + 4, -3));
325
326 if ($comment !== '' && $comment[0] === '/') {
327 return '';
328 }
329
330 return $comment;
331 }
332
333 /**
334 * Whether a JSON-LD block was emitted by ThinkRank or ThinkRank Pro.
335 *
336 * Two markers, because the plugins have two kinds of emit point. Every
337 * block ThinkRank writes from PHP carries a `data-thinkrank*` attribute
338 * (`data-thinkrank-custom-schema` on Pro's custom-schema output predates
339 * this check and is matched by the same prefix). The graph and Pro's Local
340 * SEO output additionally sit inside a named HTML comment, which is kept as
341 * a second signal so a site running an older Pro build is not reported as
342 * conflicting with itself.
343 *
344 * @param string $attributes Attribute text from the opening script tag.
345 * @param string $preceding Comment immediately before the tag.
346 * @return bool
347 */
348 private function is_ours(string $attributes, string $preceding): bool {
349 if (stripos($attributes, 'data-thinkrank') !== false) {
350 return true;
351 }
352
353 return $preceding !== '' && stripos($preceding, 'thinkrank') !== false;
354 }
355
356 /**
357 * Name the plugin that emitted a foreign block.
358 *
359 * Falls back to correlating with the active-plugin list: when exactly one
360 * other schema-capable SEO plugin is running, an unattributed block is
361 * almost certainly its, and naming it beats telling the user "something on
362 * this page". That case is flagged with `guess` so the wording can hedge.
363 *
364 * @param array{tag:string, preceding:string} $block Parsed block.
365 * @return array{slug:string, name:string, guess:bool}
366 */
367 private function attribute(array $block): array {
368 $classes = $this->class_tokens($block['tag']);
369 $tag_id = $this->tag_id($block['tag']);
370 $comment = strtolower($block['preceding']);
371
372 foreach (self::SIGNATURES as $slug => $signature) {
373 if ($tag_id !== '' && in_array($tag_id, $signature['id'] ?? [], true)) {
374 return ['slug' => $slug, 'name' => $signature['name'], 'guess' => false];
375 }
376
377 foreach ($signature['class'] as $class) {
378 if (in_array($class, $classes, true)) {
379 return ['slug' => $slug, 'name' => $signature['name'], 'guess' => false];
380 }
381 }
382
383 foreach ($signature['comment'] as $needle) {
384 if ($comment !== '' && strpos($comment, $needle) !== false) {
385 return ['slug' => $slug, 'name' => $signature['name'], 'guess' => false];
386 }
387 }
388 }
389
390 $active = $this->other_active_seo_plugins();
391
392 if (count($active) === 1) {
393 $slug = array_key_first($active);
394 return ['slug' => $slug, 'name' => $active[$slug], 'guess' => true];
395 }
396
397 return [
398 'slug' => 'unknown',
399 'name' => __('an unidentified plugin or theme', 'thinkrank'),
400 'guess' => true,
401 ];
402 }
403
404 /**
405 * Active third-party SEO plugins, as slug => display name.
406 *
407 * @return array<string, string>
408 */
409 private function other_active_seo_plugins(): array {
410 $names = [];
411
412 foreach (SEO_Plugin_Detector::detect_plugins() as $slug => $plugin) {
413 $names[$slug] = (string) ($plugin['name'] ?? $slug);
414 }
415
416 return $names;
417 }
418
419 /**
420 * The id attribute of a script tag, lowercased.
421 *
422 * @since 2.15.0
423 *
424 * @param string $attributes Attribute text from the opening script tag.
425 * @return string Id, or an empty string when the tag has none.
426 */
427 private function tag_id(string $attributes): string {
428 // Whitespace before the name, so `data-id` is not read as `id`.
429 if (!preg_match('#(?:^|\s)id\s*=\s*["\']([^"\']*)["\']#i', $attributes, $match)) {
430 return '';
431 }
432
433 return strtolower(trim($match[1]));
434 }
435
436 /**
437 * Class tokens on a script tag.
438 *
439 * @param string $attributes Attribute text from the opening script tag.
440 * @return string[]
441 */
442 private function class_tokens(string $attributes): array {
443 if (!preg_match('#\bclass\s*=\s*["\']([^"\']*)["\']#i', $attributes, $match)) {
444 return [];
445 }
446
447 return preg_split('/\s+/', strtolower(trim($match[1]))) ?: [];
448 }
449
450 /**
451 * Decode a block's JSON, tolerating the shapes emitters actually use.
452 *
453 * @param string $json Raw script contents.
454 * @return array<mixed> Decoded data, or an empty array when unusable.
455 */
456 private function decode(string $json): array {
457 $decoded = json_decode(trim($json), true);
458
459 return is_array($decoded) ? $decoded : [];
460 }
461
462 /**
463 * Every `@type` in a decoded block.
464 *
465 * A block may hold a single entity, a bare list of entities, or an object
466 * wrapping `@graph`, and `@type` itself may be a string or a list — so the
467 * walk is recursive rather than assuming one shape.
468 *
469 * @param mixed $data Decoded JSON-LD.
470 * @return string[]
471 */
472 private function collect_types($data): array {
473 if (!is_array($data)) {
474 return [];
475 }
476
477 $types = [];
478
479 if (isset($data['@type'])) {
480 foreach ((array) $data['@type'] as $type) {
481 if (is_string($type) && $type !== '') {
482 $types[] = $type;
483 }
484 }
485 }
486
487 foreach ($data as $key => $value) {
488 if ($key === '@type' || !is_array($value)) {
489 continue;
490 }
491
492 $types = array_merge($types, $this->collect_types($value));
493 }
494
495 return $types;
496 }
497
498 /**
499 * Normalise a type list: unique, sorted, empties dropped.
500 *
501 * @param string[] $types Raw type list.
502 * @return string[]
503 */
504 private function unique_types(array $types): array {
505 $types = array_values(array_unique(array_filter($types)));
506 sort($types);
507
508 return $types;
509 }
510
511 /**
512 * Keep only the types a duplicate of which is actually a problem.
513 *
514 * @param string[] $types Type list.
515 * @return string[]
516 */
517 private function page_level_only(array $types): array {
518 // Every LocalBusiness subtype is the same kind of entity for this
519 // purpose. ThinkRank publishes the subtype the site chose, so a
520 // "Dentist" of ours beside another plugin's "LocalBusiness" is still
521 // two businesses on one URL, and must still be reported as one.
522 $types = array_map(
523 static fn($type) => \ThinkRank\Config\Local_Business_Types_Config::is_local_business($type) ? 'LocalBusiness' : $type,
524 $types
525 );
526
527 return array_values(array_unique(array_intersect($types, self::PAGE_LEVEL_TYPES)));
528 }
529
530 /**
531 * The URL to scan.
532 *
533 * The most recent published post, because a single post carries more
534 * page-level entities than the front page on most sites, and falls back to
535 * the home URL when there is no post to use.
536 *
537 * @return string
538 */
539 private function representative_url(): string {
540 /**
541 * Filters the URL the schema-conflict scan fetches.
542 *
543 * @since 2.9.0
544 *
545 * @param string $url Representative front-end URL.
546 */
547 $filtered = apply_filters('thinkrank_schema_conflict_scan_url', '');
548
549 if (is_string($filtered) && $filtered !== '') {
550 return $filtered;
551 }
552
553 $posts = get_posts([
554 'numberposts' => 1,
555 'post_status' => 'publish',
556 'post_type' => 'post',
557 'has_password' => false,
558 'suppress_filters' => false,
559 'fields' => 'ids',
560 ]);
561
562 if (!empty($posts)) {
563 $permalink = get_permalink((int) $posts[0]);
564
565 if (is_string($permalink) && $permalink !== '') {
566 return $permalink;
567 }
568 }
569
570 return home_url('/');
571 }
572
573 /**
574 * Fetch a front-end URL from this server.
575 *
576 * Anonymous (no cookies) so the scan sees what a search engine sees, and
577 * `sslverify` off because this is a self-request — a local or self-signed
578 * certificate must not read as a conflict-free page. Mirrors how WP Site
579 * Health runs its own loopback probes, and how Instant Indexing checks its
580 * key file.
581 *
582 * @param string $url URL to fetch.
583 * @return string|\WP_Error Response body, or the failure.
584 */
585 private function fetch(string $url) {
586 $response = wp_remote_get(
587 $url,
588 [
589 'timeout' => self::FETCH_TIMEOUT,
590 'sslverify' => false,
591 'redirection' => 3,
592 'user-agent' => 'ThinkRank-SchemaConflictCheck/1.0',
593 'headers' => ['Cache-Control' => 'no-cache'],
594 ]
595 );
596
597 if (is_wp_error($response)) {
598 return $response;
599 }
600
601 $code = (int) wp_remote_retrieve_response_code($response);
602
603 if ($code !== 200) {
604 return new \WP_Error(
605 'thinkrank_scan_http_error',
606 sprintf(
607 /* translators: %d: HTTP status code. */
608 __('The page returned HTTP %d, so it could not be checked.', 'thinkrank'),
609 $code
610 )
611 );
612 }
613
614 $body = (string) wp_remote_retrieve_body($response);
615
616 if ($body === '') {
617 return new \WP_Error(
618 'thinkrank_scan_empty_body',
619 __('The page returned an empty response, so it could not be checked.', 'thinkrank')
620 );
621 }
622
623 return $body;
624 }
625 }
626