> $findings * @return array> */ public function dedupe(array $findings): array { $findings = $this->merge_id_groups($findings); $by_topic = []; $passthrough = []; foreach ($findings as $f) { if (!is_array($f)) { continue; } $topic = $this->classify_topic($f); if ($topic === '') { $passthrough[] = $f; continue; } if (!isset($by_topic[$topic])) { $by_topic[$topic] = []; } $by_topic[$topic][] = $f; } $result = []; foreach ($by_topic as $topic => $list) { $has_sitewide = false; foreach ($list as $f) { if (empty($this->extract_url_set($f))) { $has_sitewide = true; break; } } if ($has_sitewide || count($list) === 1) { $result[] = $this->merge_to_keeper($list); continue; } // All findings have URL sets — group by exact URL-set hash so // disjoint problems stay separate. $by_urlset = []; foreach ($list as $f) { $urls = $this->extract_url_set($f); sort($urls); $h = md5(implode("\n", $urls)); if (!isset($by_urlset[$h])) { $by_urlset[$h] = []; } $by_urlset[$h][] = $f; } foreach ($by_urlset as $group) { $result[] = $this->merge_to_keeper($group); } } return array_merge($result, $passthrough); } /** * 4.0.18: finding ids that describe one root cause from different checks. * Matching by id is language-independent (titles now exist in two * languages) and merges even when the sampled URL sets differ, because * the fix is the same. The most negative score_impact survives. */ private const ID_GROUPS = [ 'twitter_card' => ['seo_twitter_card_missing', 'ai_twitter_card_missing'], // ai_jsonld_missing only fires when no checked page has JSON-LD, so // the homepage Organization/WebSite gap is the same missing markup. 'page_jsonld' => ['ai_jsonld_missing', 'schema_no_structured_data', 'schema_missing_required'], 'thin_content' => ['onpage_very_thin_content', 'onpage_thin_content_heuristic', 'pro_on_page_depth_thin_sections'], 'duplicate_titles' => ['onpage_duplicate_titles', 'crawl_duplicate_titles'], 'freshness' => ['ai_content_freshness_signals_missing', 'seo_missing_last_modified'], 'nextgen_images' => ['perf_low_webp_coverage', 'media_format_adoption_stats', 'media_no_nextgen_formats'], 'broken_links' => ['crawl_4xx_5xx_detected', 'seo_broken_internal_links', 'sample_page_http_error'], 'og_tags' => ['seo_og_tags_missing', 'ai_og_tags_missing'], // Pairs that the title-text topics used to catch; titles are now // rewritten and bilingual, so they are matched by id instead. 'canonical_missing' => ['seo_missing_canonical', 'crawl_missing_canonical'], 'noindex' => ['seo_noindex_detected', 'crawl_noindex_pages'], 'redirect_chain' => ['seo_redirect_chains', 'crawl_redirect_chains'], 'html_lang_missing' => ['seo_html_lang_missing', 'mobile_lang_attribute_missing'], 'optimizer_overlap' => ['perf_multiple_asset_optimizers', 'perf_builder_optimizer_overlap'], 'faq_answers' => ['schema_faqpage_missing_answers', 'eeat_faqpage_incomplete_answers'], ]; /** * @param array> $findings * @return array> */ private function merge_id_groups(array $findings): array { $group_of = []; foreach (self::ID_GROUPS as $group => $ids) { foreach ($ids as $id) { $group_of[$id] = $group; } } $groups = []; $out = []; foreach ($findings as $i => $f) { $id = is_array($f) ? (string) ($f['id'] ?? '') : ''; if ($id !== '' && isset($group_of[$id])) { $groups[$group_of[$id]][] = $f; $out[$i] = null; // placeholder keeps the first member's position if (count($groups[$group_of[$id]]) > 1) { unset($out[$i]); } continue; } $out[$i] = $f; } $result = []; $emitted = []; foreach ($out as $i => $f) { if ($f !== null) { $result[] = $f; continue; } $group = $group_of[(string) ($findings[$i]['id'] ?? '')]; if (!isset($emitted[$group])) { $result[] = $this->merge_to_keeper($groups[$group], array_flip(self::ID_GROUPS[$group])); $emitted[$group] = true; } } return $result; } /** * Pick the most-impactful finding from a group, fold the others into * its evidence.merged_from list so the UI can show provenance. * * @param array> $group * @return array */ private function merge_to_keeper(array $group, array $rank = []): array { if (count($group) === 1) { return $group[0]; } // Sort by score_impact ascending (most negative = most impactful). // Ties go to the id listed first in ID_GROUPS (the broader finding); // explicit because usort is not stable on PHP 7.4. usort($group, static function (array $a, array $b) use ($rank): int { $by_impact = (int) ($a['score_impact'] ?? 0) <=> (int) ($b['score_impact'] ?? 0); if ($by_impact !== 0) { return $by_impact; } $ra = $rank[(string) ($a['id'] ?? '')] ?? PHP_INT_MAX; $rb = $rank[(string) ($b['id'] ?? '')] ?? PHP_INT_MAX; return $ra <=> $rb; }); $keeper = $group[0]; $merged = isset($keeper['evidence']['merged_from']) && is_array($keeper['evidence']['merged_from']) ? $keeper['evidence']['merged_from'] : []; for ($i = 1; $i < count($group); $i++) { $loser = $group[$i]; $merged[] = [ 'category' => (string) ($loser['category'] ?? ''), 'check_id' => (string) ($loser['check_id'] ?? ''), 'title' => (string) ($loser['title'] ?? ''), 'score_impact' => (int) ($loser['score_impact'] ?? 0), ]; // Carry up any URL samples from absorbed findings so the // keeper's evidence isn't artificially narrow. $loser_urls = $this->extract_url_set($loser); if (!empty($loser_urls)) { if (!isset($keeper['evidence']) || !is_array($keeper['evidence'])) { $keeper['evidence'] = []; } if (!isset($keeper['evidence']['samples']) || !is_array($keeper['evidence']['samples'])) { $keeper['evidence']['samples'] = []; } $existing_urls = []; foreach ($keeper['evidence']['samples'] as $s) { if (is_array($s) && isset($s['url'])) { $existing_urls[(string) $s['url']] = true; } elseif (is_string($s)) { $existing_urls[$s] = true; } } foreach ($loser_urls as $u) { if (!isset($existing_urls[$u]) && count($keeper['evidence']['samples']) < 25) { $keeper['evidence']['samples'][] = ['url' => $u]; $existing_urls[$u] = true; // 4.0.18: a language-specific copy of samples would // replace the merged list at display time; extend it too. foreach ((array) ($keeper['evidence']['_i18n'] ?? []) as $lang => $texts) { if (isset($texts['evidence']['samples']) && is_array($texts['evidence']['samples'])) { $keeper['evidence']['_i18n'][$lang]['evidence']['samples'][] = ['url' => $u]; } } } } } } if (!isset($keeper['evidence']) || !is_array($keeper['evidence'])) { $keeper['evidence'] = []; } $keeper['evidence']['merged_from'] = $merged; return $keeper; } /** * v3.3.47 — public wrapper around classify_topic() for the diff engine. * The diff uses the same topic_map as a stable cross-run identity so a * check_id rename doesn't make the same root cause appear as * both "new" and "resolved" in the same diff. */ public function classify_topic_public(array $f): string { return $this->classify_topic($f); } /** * Classify a finding into a coarse topic. Only findings that match a * known topic are eligible for dedup — everything else passes through * untouched, so this is conservative by design. */ private function classify_topic(array $f): string { $haystack = strtolower( (string) ($f['title'] ?? '') . ' ' . (string) ($f['issue_code'] ?? '') . ' ' . (string) ($f['check_id'] ?? '') ); $ev_details = ''; if (isset($f['evidence']) && is_array($f['evidence']) && isset($f['evidence']['details'])) { $ev_details = strtolower((string) $f['evidence']['details']); } $haystack .= ' ' . $ev_details; $topic_map = [ 'noindex' => ['noindex'], 'canonical_missing' => ['canonical مفقود', 'missing canonical', 'canonical_missing', 'canonical link'], 'title_year_old' => [ 'سنة قديمة في الـ title', 'سنوات قديمة مذكورة', 'سنوات قديمة في الـ', 'years old in titles', 'old year', 'outdated year', 'year_in_title_outdated', ], 'duplicate_titles' => ['duplicate_titles', 'titles مكررة', 'duplicate title'], 'duplicate_meta' => ['duplicate_meta', 'meta description مكرر'], // v2.8.3 — merge thin tiers (very_thin <150 + thin 150-300) into one topic. // Both fire for the same root cause; user only needs one finding. 'thin_content' => [ 'thin_content', 'محتوى خفيف', 'word_count <', 'محتوى خفيف جداً', 'محتوى خفيف (', 'low_text_html_ratio', 'نسبة النص إلى html', ], 'broken_links' => ['broken_link', '4xx', '5xx', 'broken link'], 'redirect_chain' => ['redirect_chain', 'redirect chain'], 'orphan_pages' => ['orphan', 'بدون روابط داخلية', 'no inbound'], 'missing_h1' => ['missing_h1', 'h1 مفقود'], 'multiple_h1' => ['multiple_h1', 'h1 متعدد'], 'mixed_content' => ['mixed_content', 'محتوى مختلط'], // v2.8.3 — sameAs cluster. Run #4 had 5 separate findings all // describing the same root cause (Organization JSON-LD lacks // sameAs links to social/Wikipedia/Wikidata). Collapse to one // -5 finding instead of -5/-2/-2/-2/-2 = -13. 'sameas_missing' => [ 'sameas', 'sameAs', 'إشارات تعريف الكيانات', 'organization بدون sameas', 'social search profile coverage', 'entity profile diversity', 'social_search_low', 'entity_diversity_low', 'organization_no_sameas', ], // v2.8.3 — old/stale content. AI-Friendly's "12-24 months not // updated" + On-Page's "1+ year old content" are the same // signal; emit one finding only. 'stale_content' => [ 'لم تُحدَّث في 12-24', 'محتوى قديم بدون تحديث', 'content_freshness', 'stale content', 'outdated content', 'no_update_since', 'last_modified_old', ], // v2.8.3 — performance optimizer overlap. PerformanceCheck and // EnvironmentCheck both detect "multiple optimizer plugins" // separately. They're the same finding with different scope. 'perf_optimizer_overlap' => [ 'multi-optimizer overlap', 'multiple css/js optimization plugins', 'optimizer_conflict', 'optimizer overlap', 'multiple optimizers detected', ], // v2.8.3 — long-content / wall-of-text variants. 'long_content_no_structure' => [ 'wall of text', 'wall_of_text', 'long content no structure', 'صفحات طويلة بدون تقسيم', 'long_content_no_h2', ], // v2.8.3 — heading hierarchy issues. 'heading_hierarchy' => [ 'تسلسل headings غير صحيح', 'heading hierarchy', 'h1→h3', 'h1->h3', 'heading_skip', 'broken_heading_hierarchy', ], // v3.3.47 — Round-2 false-positive fix. TechnicalSeoCheck + // MobileUsabilityCheck both emit the same "lang missing on " // finding for the same root cause. Collapse to one keeper. 'html_lang_missing' => [ 'seo_html_lang_missing', 'mobile_lang_attribute_missing', 'خاصية lang مفقودة', 'lang attribute', 'missing lang', ], // v3.3.47 — AdvancedSchemaCheck + EeatCheck both emit // "FAQPage questions without acceptedAnswer". Same root cause, // same JSON-LD source. Collapse to one keeper. 'faqpage_missing_answer' => [ 'schema_faqpage_missing_answers', 'eeat_faqpage_incomplete_answers', 'faqpage schema', 'questions without acceptedanswer', 'acceptedanswer', 'أسئلة بدون acceptedanswer', ], ]; foreach ($topic_map as $topic => $needles) { foreach ($needles as $needle) { if (strpos($haystack, $needle) !== false) { return $topic; } } } return ''; } /** * Extract the deduplicating URL set from a finding's evidence. * @return array */ private function extract_url_set(array $f): array { if (!isset($f['evidence']) || !is_array($f['evidence'])) { return []; } $urls = []; if (isset($f['evidence']['samples']) && is_array($f['evidence']['samples'])) { foreach ($f['evidence']['samples'] as $sample) { if (is_array($sample) && isset($sample['url']) && is_string($sample['url'])) { $urls[] = $sample['url']; } elseif (is_string($sample)) { $urls[] = $sample; } } } if (isset($f['evidence']['urls']) && is_array($f['evidence']['urls'])) { foreach ($f['evidence']['urls'] as $u) { if (is_string($u)) { $urls[] = $u; } } } return array_values(array_unique($urls)); } }