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

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