*/ private const SIGNATURES = [ 'yoast' => [ 'name' => 'Yoast SEO', 'class' => ['yoast-schema-graph'], 'comment' => ['yoast seo plugin'], ], 'rankmath' => [ 'name' => 'Rank Math', 'class' => ['rank-math-schema', 'rank-math-schema-pro'], 'comment' => ['rank math wordpress seo'], ], 'aioseo' => [ 'name' => 'All in One SEO', 'class' => ['aioseo-schema'], 'comment' => ['all in one seo'], ], 'seopress' => [ 'name' => 'SEOPress', 'class' => ['seopress-schema'], 'comment' => ['seopress'], ], 'theseoframework' => [ 'name' => 'The SEO Framework', 'class' => [], 'comment' => ['the seo framework'], ], 'slimseo' => [ 'name' => 'Slim SEO', 'class' => ['slim-seo-schema'], 'comment' => ['slim seo'], ], 'woocommerce' => [ 'name' => 'WooCommerce', 'class' => [], 'comment' => ['woocommerce json-ld'], ], ]; /** * Schema types worth warning about when both sides publish one. * * A duplicated `WebPage` or `Organization` is the conflict users get * penalised for. A second `SearchAction` or `ImageObject` is noise, so the * notice stays about entities a search engine reconciles per URL. * * @var string[] */ private const PAGE_LEVEL_TYPES = [ 'Article', 'BlogPosting', 'NewsArticle', 'BreadcrumbList', 'CollectionPage', 'ContactPage', 'Event', 'FAQPage', 'HowTo', 'ItemList', 'LocalBusiness', 'Organization', 'Person', 'Product', 'ProfilePage', 'Recipe', 'SearchResultsPage', 'SoftwareApplication', 'VideoObject', 'WebPage', 'WebSite', ]; /** * Run a scan, using the cached result when one is fresh. * * @param bool $use_cache Whether a cached result may be returned. * @return array Scan report, see build_report(). */ public function scan(bool $use_cache = true): array { if ($use_cache) { $cached = get_transient(self::CACHE_KEY); if (is_array($cached)) { return $cached; } } $url = $this->representative_url(); $html = $this->fetch($url); if (is_wp_error($html)) { $report = [ 'scanned_url' => $url, 'error' => $html->get_error_message(), 'conflicts' => [], 'foreign' => [], 'own_types' => [], 'checked_at' => time(), ]; } else { $report = $this->analyze($html); $report['scanned_url'] = $url; $report['checked_at'] = time(); } set_transient(self::CACHE_KEY, $report, self::CACHE_TTL); return $report; } /** * Drop the cached scan result. * * @return void */ public static function flush_cache(): void { delete_transient(self::CACHE_KEY); } /** * Analyse a rendered HTML document for foreign JSON-LD. * * Kept separate from the fetch so it can be exercised against a fixture * without an HTTP request. * * @param string $html Rendered page HTML. * @return array{conflicts:array, foreign:array, own_types:string[]} */ public function analyze(string $html): array { $blocks = $this->extract_blocks($html); $own_types = []; $foreign = []; foreach ($blocks as $block) { $types = $this->collect_types($this->decode($block['json'])); if ($block['is_ours']) { $own_types = array_merge($own_types, $types); continue; } $source = $this->attribute($block); if (!isset($foreign[$source['slug']])) { $foreign[$source['slug']] = [ 'slug' => $source['slug'], 'name' => $source['name'], 'guess' => $source['guess'], 'types' => [], 'blocks' => 0, ]; } $foreign[$source['slug']]['types'] = array_merge($foreign[$source['slug']]['types'], $types); $foreign[$source['slug']]['blocks']++; } $own_types = $this->unique_types($own_types); $conflicts = []; foreach ($foreign as $slug => $entry) { $entry['types'] = $this->unique_types($entry['types']); $foreign[$slug] = $entry; $duplicated = array_values(array_intersect( $this->page_level_only($own_types), $this->page_level_only($entry['types']) )); if (!empty($duplicated)) { $conflicts[] = [ 'slug' => $slug, 'name' => $entry['name'], 'guess' => $entry['guess'], 'duplicated' => $duplicated, ]; } } return [ 'conflicts' => $conflicts, 'foreign' => array_values($foreign), 'own_types' => $own_types, ]; } /** * Pull every `application/ld+json` block out of a document. * * Regex rather than DOMDocument: the scan runs against whatever a third * party emitted, and a malformed document must still yield the blocks that * did parse. Each block carries the raw tag and the comment immediately * preceding it, which is what attribution reads. * * @param string $html Rendered page HTML. * @return array */ private function extract_blocks(string $html): array { $pattern = '#]*\btype\s*=\s*["\']application/ld\+json["\'][^>]*)>(.*?)#is'; if (!preg_match_all($pattern, $html, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER)) { return []; } $blocks = []; foreach ($matches as $match) { $attributes = $match[1][0]; $offset = (int) $match[0][1]; $preceding = $this->preceding_comment($html, $offset); $blocks[] = [ 'json' => $match[2][0], 'tag' => $attributes, 'preceding' => $preceding, 'is_ours' => $this->is_ours($attributes, $preceding), ]; } return $blocks; } /** * The HTML comment that ends just before a script block, if any. * * Only whitespace may sit between the comment and the tag, so an unrelated * comment further up the head is never treated as the block's label. * * A *closing* comment is not a label. Both ThinkRank and Yoast bracket their * head output in a matched pair, so the text immediately before a block is * very often the previous block's `` — reading that as the label * made every plugin's schema look like ThinkRank's the moment it happened to * be printed next. * * @param string $html Rendered page HTML. * @param int $offset Byte offset of the script tag. * @return string Comment body (without the delimiters), or an empty string. */ private function preceding_comment(string $html, int $offset): string { $before = rtrim(substr($html, 0, $offset)); if (substr($before, -3) !== '-->') { return ''; } $start = strrpos($before, '