get('crawl_rows', []); $errs = []; $error_pages_skipped_html = 0; // v3.3.52 P0.2 — count of 4xx/5xx/status-0 rows excluded from HTML-derived findings. $redirects = []; $missing_can = []; $noindex = []; $title_map = []; $nofollow_internal_pages = []; $malformed_urls = []; // v1.0.78: URLs with a repeated domain (e.g. example.com/example.com/...) $url_seen = []; // normalized url => true (all crawled URLs) $url_inbound = []; // normalized url => count of inbound internal links $nav_inbound = []; // 4.0.18: normalized url => linked from a menu/header/footer/sidebar $has_nav_data = false; // 4.0.18: false on crawl rows stored before nav_links existed // v2.2.0 — canonical resolution map: raw URL → canonical/redirect target. // Used to attribute inbound link counts to the canonical version of // a URL so that aliases (e.g. /old-slug/ → /new-slug/) don't show up // as orphans when their canonical target has plenty of inbound links. $canonical_for = []; // norm_url => norm_canonical_or_redirect_target $redirect_chain_target = []; // norm_url => norm_final_url (from redirect_chain) // Pass 1: collect canonical + redirect maps before processing inbound links. foreach ($rows as $r) { if (!is_array($r)) { continue; } $url0 = (string) ($r['url'] ?? ''); $canonical0 = (string) ($r['canonical'] ?? ''); $chain0 = isset($r['redirect_chain']) ? $r['redirect_chain'] : []; $norm0 = Url::normalize($url0); if ($norm0 === '') { continue; } $can_norm = $canonical0 !== '' ? Url::normalize($canonical0) : ''; if ($can_norm !== '' && $can_norm !== $norm0) { $canonical_for[$norm0] = $can_norm; } // Final URL after redirect chain (last hop's url). if (is_array($chain0) && !empty($chain0)) { $last = end($chain0); if (is_array($last) && !empty($last['url'])) { $final_norm = Url::normalize((string) $last['url']); if ($final_norm !== '' && $final_norm !== $norm0) { $redirect_chain_target[$norm0] = $final_norm; } } } } foreach ($rows as $r) { if (!is_array($r)) { continue; } $url = (string) ($r['url'] ?? ''); $code = (int) ($r['status_code'] ?? 0); $canonical = (string) ($r['canonical'] ?? ''); $title = (string) ($r['title'] ?? ''); $chain = isset($r['redirect_chain']) ? $r['redirect_chain'] : []; // v2.8.3 — UrlClassifier shared utility (was inline regex array). // Skips excluded URL patterns (auto-redirects, admin, cart, REST, // feeds) from finding aggregation but keeps them in the inbound // link map so orphan detection still works correctly. if (\AetosSeo\Utils\UrlClassifier::is_excluded($url)) { $norm_excl = Url::normalize($url); if ($norm_excl !== '') { $url_seen[$norm_excl] = true; if (!isset($url_inbound[$norm_excl])) { $url_inbound[$norm_excl] = 0; } } continue; } $norm = Url::normalize($url); // v1.0.78: detect malformed URLs (domain repeated in path). // URL-string based, so it runs regardless of HTTP status. $parsed_url = wp_parse_url($url); $url_host = isset($parsed_url['host']) ? Str::lower((string) $parsed_url['host']) : ''; $url_path = isset($parsed_url['path']) ? (string) $parsed_url['path'] : ''; if ($url_host !== '' && strpos($url_path, '/' . $url_host) !== false) { $malformed_urls[] = ['url' => $url, 'reason' => 'domain_in_path']; } // v3.3.52 P0.2 — HTTP error / unreachable pages (4xx, 5xx, or // status 0 = connection failure / timeout / WP_Error) are recorded // as crawl errors and then SKIPPED for every HTML-derived signal. // Their canonical is empty and their body is an error/challenge // page (Cloudflare 520, WAF 403, soft-404), so feeding them into // missing-canonical / noindex / duplicate-title / orphan / inbound // logic produced false positives — e.g. "this article has no // canonical" or "is noindex" when the real evidence was a transient // 403. Mirrors the guard already in TechnicalSeoCheck. // • Not added to $url_seen → never flagged as an orphan. // • internal_links not trusted → no inbound-link pollution. // • canonical / noindex / title checks bypassed entirely. if ($code >= 400 || $code === 0) { $errs[] = ['url' => $url, 'status' => $code]; $error_pages_skipped_html++; continue; } if ($norm !== '') { $url_seen[$norm] = true; if (!isset($url_inbound[$norm])) { $url_inbound[$norm] = 0; } } // v1.0.78: 301→200 is normal. Chain = 3+ hops. if (is_array($chain) && count($chain) >= 3) { $redirects[] = ['url' => $url, 'chain' => $chain]; } if ($canonical === '') { $missing_can[] = ['url' => $url]; } if (!empty($r['robots_noindex'])) { $noindex[] = ['url' => $url]; } // nofollow internal links $nofollow_internal = (int) ($r['nofollow_internal_count'] ?? 0); if ($nofollow_internal > 0) { $nofollow_internal_pages[] = ['url' => $url, 'nofollow_count' => $nofollow_internal]; } // Build inbound link map for orphan detection. // v1.0.78: also decode analysis_json to get internal_links when // the DB row doesn't have them at top level (reuse mode stores // links inside the JSON payload, not as a DB column). $links = isset($r['internal_links']) && is_array($r['internal_links']) ? $r['internal_links'] : []; if (empty($links) && isset($r['analysis_json']) && is_string($r['analysis_json']) && $r['analysis_json'] !== '') { $decoded_analysis = json_decode($r['analysis_json'], true); if (is_array($decoded_analysis) && isset($decoded_analysis['internal_links']) && is_array($decoded_analysis['internal_links'])) { $links = $decoded_analysis['internal_links']; } } foreach ($links as $link) { if (!is_string($link) || $link === '') { continue; } if (!Url::is_internal_url($link)) { continue; } $lnorm = Url::normalize($link); if ($lnorm === '' || $lnorm === $norm) { continue; } // v2.2.0 — resolve link to its canonical / final-redirect target // so that links to /old-slug/ (which 301→/new-slug/) get // credited to /new-slug/. Avoids classifying the redirect // alias as orphan when the canonical has plenty of inbound. $resolved = $lnorm; if (isset($canonical_for[$resolved])) { $resolved = $canonical_for[$resolved]; } if (isset($redirect_chain_target[$resolved])) { $resolved = $redirect_chain_target[$resolved]; } if (!isset($url_inbound[$lnorm])) { $url_inbound[$lnorm] = 0; } $url_inbound[$lnorm]++; // Also increment the canonical's count (so the canonical // shows the combined inbound from all its aliases). if ($resolved !== $lnorm) { if (!isset($url_inbound[$resolved])) { $url_inbound[$resolved] = 0; } $url_inbound[$resolved]++; } } // 4.0.18: links from menus, header, footer and sidebars. Used only // to separate true orphans (no link anywhere) from menu-only pages. if (array_key_exists('nav_links', $r)) { $has_nav_data = true; foreach ((array) $r['nav_links'] as $nav_link) { if (!is_string($nav_link) || $nav_link === '') { continue; } $nnorm = Url::normalize($nav_link); if ($nnorm === '' || $nnorm === $norm) { continue; } $nav_inbound[$nnorm] = true; if (isset($redirect_chain_target[$nnorm])) { $nav_inbound[$redirect_chain_target[$nnorm]] = true; } } } // v2.0.1: only count a URL as a "duplicate title" candidate if it // is genuinely indexable. Exclude: // - non-200 responses (redirects, 404s, errors) // - noindex pages // - pages whose canonical points to a DIFFERENT URL (they are // explicit aliases — the canonical target gets counted, not // the alias) // This eliminates the false-positive "duplicate titles" group on // sites where Rank Math redirect rules + alias slugs share titles // with their canonical destinations. $tkey = Str::lower(trim($title)); // v3.3.52 P0.2 — error/unreachable rows already `continue`d above, // so here $code is always a real 2xx. Only genuine indexable pages // (200-class, not noindex) become duplicate-title candidates. $is_indexable = ($code >= 200 && $code < 300) && empty($r['robots_noindex']); $canonical_norm = $canonical !== '' ? Url::normalize($canonical) : ''; $self_norm = Url::normalize($url); $canonical_self_or_empty = $canonical_norm === '' || $canonical_norm === $self_norm; if ($tkey !== '' && $is_indexable && $canonical_self_or_empty) { if (!isset($title_map[$tkey])) { $title_map[$tkey] = []; } $url_key = $self_norm !== '' ? $self_norm : $url; $title_map[$tkey][$url_key] = $url_key; } } // Detect orphan pages: crawled URLs with zero inbound internal links. // v2.2.0 — skip URLs that are themselves aliases (canonical or // redirect target points to a different URL with inbound links). // Don't classify the alias as orphan when the canonical IS linked. // // v2.1.1 tiered: policy/legal pages don't need contextual internal // links; they live in the footer by design. // Tier 1 (commercial/service/blog): no contextual links = ranking gap. // Tier 2 (privacy/terms/refund/cookies/legal): expected → info, 0 impact. // // v3.3.52 P1.5 — classify each orphan INLINE and keep UNCAPPED totals // per tier; only the sample arrays are bounded. Previously the loop // broke at 30 orphans, so on large sites the finding title printed the // cap (30) as if it were the true total. Now the title shows the real // count and `affected_count`/`shown_in_samples` make the sampling // explicit. $legal_patterns = [ // v2.1.4 — user-agreement, data-processing, complaints, and // dispute-resolution patterns based on real e-commerce compliance // pages found on a production site. '/privacy', '/terms', '/refund', '/return', '/cookies', '/cookie-', '/legal', '/disclaimer', '/سياسة-الخصوصية', '/الشروط', '/سياسة', '/الاسترجاع', '/كوكيز', '/شروط-الاستخدام', '/policies', '-policy', '/dmca', '/gdpr', '/accessibility', '/sla', '/service-level-agreement', '/intellectual-property', '/copyright', '/license', '/license-agreement', '/eula', '/إخلاء-المسؤولية', '/اخلاء-المسؤولية', '/حقوق-الملكية', '/شروط-الخدمة', '/اتفاقية', '/اتفاقية-الخدمة', // v2.1.4 — e-commerce / SaaS compliance patterns. '/user-agreement', '/data-processing', '/data-processing-agreement', '/complaints', '/dispute-resolution', '/dispute', '/grievance', '/cookie-preferences', '/cookie-consent', '/consent', '/data-protection', '/privacy-notice', '/privacy-statement', '/اتفاقية-المستخدم', '/معالجة-البيانات', '/الشكاوى', '/شكاوى', '/حل-النزاعات', '/حماية-البيانات', '/إشعار-الخصوصية', // v2.1.5 — support / help / knowledge-base patterns. Navigation / // info pages, not commercial orphans; typically in footer or nav. '/help', '/help-center', '/helpcenter', '/support', '/support-center', '/knowledge-base', '/knowledgebase', '/kb', '/docs', '/documentation', '/faq', '/faqs', '/q-and-a', '/questions', '/مركز-المساعدة', '/مركز-المساعده', '/مركز-المساعده-والتدريب', '/المساعدة', '/المساعده', '/الدعم', '/دعم-فني', '/الدعم-الفني', '/الأسئلة-الشائعة', '/الاسئلة-الشائعة', ]; $orphans_commercial = []; // capped display samples $orphans_legal = []; // capped display samples $orphans_commercial_total = 0; // uncapped true total $orphans_legal_total = 0; // uncapped true total $orphans_redirecting_total = 0; // v3.3.57 — excluded: an active redirect already points away $orphans_true = []; // 4.0.18 — no link from content or navigation $orphans_true_total = 0; $orphan_sample_cap = 50; // generous for display, bounded for memory // v3.3.57 — pages that already have an ACTIVE redirect configured // (Rank Math Redirections / the Redirection plugin) must not be // recommended as internal-link targets: building links to a URL that // 301s away wastes the work and splits signals. run-33 recommended // linking 2 pages that were scheduled for a 301 merge. $redirect_source_paths = $this->get_existing_redirect_source_paths(); $home_norm = Url::normalize(home_url('/')); foreach ($url_seen as $u => $_) { if ($u === $home_norm) { continue; // homepage isn't an orphan } // Skip aliases — their canonical absorbs the inbound count. if (isset($canonical_for[$u]) && (int) ($url_inbound[$canonical_for[$u]] ?? 0) > 0) { continue; } if (isset($redirect_chain_target[$u]) && (int) ($url_inbound[$redirect_chain_target[$u]] ?? 0) > 0) { continue; } if ((int) ($url_inbound[$u] ?? 0) !== 0) { continue; } $u_decoded = Str::lower(rawurldecode((string) $u)); if (!empty($redirect_source_paths)) { $u_path = Str::lower(trim(rawurldecode((string) (wp_parse_url((string) $u, PHP_URL_PATH) ?: '')), '/')); if ($u_path !== '' && isset($redirect_source_paths[$u_path])) { $orphans_redirecting_total++; continue; } } $orphan = ['url' => $u, 'inbound_links' => 0]; // 4.0.18: no link from content AND no link from any menu, header, // footer or sidebar: the page is reachable only through the // sitemap or a direct address. Reported on its own, first. if ($has_nav_data && empty($nav_inbound[$u])) { $orphans_true_total++; if (count($orphans_true) < $orphan_sample_cap) { $orphans_true[] = $orphan; } continue; } $is_legal = false; foreach ($legal_patterns as $pat) { if (strpos($u_decoded, $pat) !== false) { $is_legal = true; break; } } if ($is_legal) { $orphans_legal_total++; if (count($orphans_legal) < $orphan_sample_cap) { $orphans_legal[] = $orphan; } } else { $orphans_commercial_total++; if (count($orphans_commercial) < $orphan_sample_cap) { $orphans_commercial[] = $orphan; } } } if (!empty($errs)) { $findings[] = $this->finding( 'crawl_4xx_5xx_detected', I18n::tr('Links that lead to error pages', 'روابط تؤدي إلى صفحات خطأ'), 'Crawl', 'high', -8, [ 'source' => 'crawl', 'details' => sprintf(I18n::tr('%d pages answered with an error.', 'عدد الصفحات التي أعادت خطأ: %d.'), count($errs)), 'affected_count' => count($errs), 'shown_in_samples' => min(20, count($errs)), 'samples' => array_slice($errs, 0, 20), // v3.3.52 P0.2 — be explicit that these pages were held out // of the content-quality checks so the evidence is honest. 'excluded_from_html_checks' => $error_pages_skipped_html, 'note' => I18n::tr('These error pages were excluded from the canonical/noindex/duplicate-titles/orphan checks because what came back was an error page, not the real content. Re-crawl after fixing to confirm.', 'استُبعدت صفحات الخطأ هذه من فحوصات الرابط الأساسي ومنع الأرشفة وتكرار العناوين والصفحات اليتيمة، لأن ما عاد كان صفحة خطأ لا المحتوى الحقيقي. أعد الفحص بعد الإصلاح للتأكد.'), ], I18n::tr('Links that end on an error page frustrate visitors, waste search engine crawling and lower trust in the site.', 'الروابط التي تنتهي بصفحة خطأ تزعج الزائر وتضيع وقت زواحف محركات البحث وتضعف ثقة جوجل في الموقع.'), [ I18n::tr('Fix the links or 404 pages, or add a suitable redirect.', 'أصلح الروابط أو صفحات 404، أو أضف تحويلًا مناسبًا.'), I18n::tr('Investigate the cause of 5xx errors (server/plugin issue).', 'إذا كانت الأخطاء من الخادم (5xx) فراجع سجل أخطاء الاستضافة أو آخر إضافة ثبّتها.'), ], [ 'how' => I18n::tr('Re-crawl after fixing.', 'أعد الزحف بعد الإصلاح.'), 'pass_condition' => I18n::tr('No crawled address answers with an error.', 'لا يعيد أي عنوان تم زحفه خطأ.'), ], $this->id() ); } if (!empty($redirects)) { $findings[] = $this->finding( 'crawl_redirect_chains', I18n::tr('Redirect chains present', 'سلاسل تحويل (Redirect chains) موجودة'), 'Crawl', 'med', -5, [ 'source' => 'crawl', 'details' => sprintf(I18n::tr('%d pages go through a redirect.', 'عدد الصفحات التي تمر عبر تحويل: %d.'), count($redirects)), 'samples' => Redactor::redact_array(array_slice($redirects, 0, 10)), ], I18n::tr('Redirect chains increase load time and consume crawl budget.', 'سلاسل التحويل تبطئ التحميل وتهدر وقت الزواحف.'), [ I18n::tr('Reduce redirects to a single hop.', 'قلّل التحويلات إلى خطوة واحدة.'), ], [ 'how' => I18n::tr('Re-crawl.', 'أعد Crawl.'), 'pass_condition' => 'redirect_chain length <= 1.', ], $this->id() ); } if (!empty($noindex)) { $findings[] = $this->finding( 'crawl_noindex_pages', I18n::tr('Pages with noindex', 'صفحات عليها منع أرشفة (noindex)'), 'Crawl', 'high', -10, [ 'source' => 'crawl', 'details' => sprintf(I18n::tr('%d pages are set to noindex.', 'عدد الصفحات التي عليها منع أرشفة (noindex): %d.'), count($noindex)), 'samples' => array_slice($noindex, 0, 10), ], I18n::tr('noindex prevents indexing. Make sure it is intentional.', 'منع الأرشفة يخفي الصفحة من البحث، فتأكد أنه مقصود.'), [ I18n::tr('Review SEO settings for each page type.', 'راجع إعدادات تحسين محركات البحث لكل نوع صفحة.'), I18n::tr('Check canonical and robots.', 'راجع الرابط الأساسي وإعدادات الأرشفة.'), ], [ 'how' => I18n::tr('Re-crawl after the change.', 'أعد الزحف بعد التعديل.'), 'pass_condition' => I18n::tr('None of the pages you want in search carry noindex.', 'noindex_count=0 للصفحات التي تريد فهرستها.'), ], $this->id() ); } if (!empty($missing_can)) { $findings[] = $this->finding( 'crawl_missing_canonical', I18n::tr('Canonical link missing', 'الرابط الأساسي (canonical) غير موجود'), 'Crawl', 'med', -4, [ 'source' => 'crawl', 'details' => sprintf(I18n::tr('%d pages have no canonical link.', 'عدد الصفحات التي بلا رابط أساسي (canonical): %d.'), count($missing_can)), 'samples' => array_slice($missing_can, 0, 10), ], I18n::tr('Canonical is important to avoid duplication and define the primary URL.', 'الرابط الأساسي يمنع مشكلات التكرار ويحدد العنوان الصحيح للصفحة.'), [ I18n::tr('Enable canonical via your SEO plugin or theme.', 'فعّل الرابط الأساسي من إضافة تحسين محركات البحث أو القالب.'), ], [ 'how' => I18n::tr('Re-crawl.', 'أعد Crawl.'), 'pass_condition' => I18n::tr('Most pages have a canonical link.', 'أغلب الصفحات تحتوي الرابط الأساسي.'), ], $this->id() ); } $dupes = []; foreach ($title_map as $t => $urls) { $unique_urls = array_values((array) $urls); if (count($unique_urls) >= 2) { $dupes[] = ['title' => $t, 'urls' => $unique_urls]; } } if (!empty($dupes)) { $findings[] = $this->finding( 'crawl_duplicate_titles', I18n::tr('Duplicate page titles', 'عناوين صفحات مكررة'), 'Crawl', 'med', -4, [ 'source' => 'crawl', 'details' => sprintf(I18n::tr('Groups of pages that share the same title: %d.', 'عدد مجموعات الصفحات التي تتشارك العنوان نفسه: %d.'), count($dupes)), 'samples' => Redactor::redact_array(array_slice($dupes, 0, 5)), ], I18n::tr('Duplicate titles can cause conflicts and loss of page distinction.', 'العناوين المكررة تجعل الصفحات متشابهة وقد تتنافس على الكلمة نفسها.'), [ I18n::tr('Make each page\'s title unique.', 'اجعل عنوان كل صفحة مختلفًا.'), ], [ 'how' => I18n::tr('Re-crawl.', 'أعد Crawl.'), 'pass_condition' => I18n::tr('No duplicate titles.', 'لا يوجد تكرار titles.'), ], $this->id() ); } // 4.0.18: pages that nothing links to at all, not even a menu. Only a // sitemap or a typed address reaches them, so visitors never find them. if ($orphans_true_total > 0) { $findings[] = $this->finding( 'crawl_true_orphan_pages', sprintf(I18n::tr('Pages that no link on the site points to (%d)', 'صفحات لا يشير إليها أي رابط في الموقع (%d)'), $orphans_true_total), 'Crawl', 'med', -4, [ 'source' => 'crawl', 'details' => sprintf(I18n::tr('%d pages were found only through the sitemap. No page, menu, header, footer or sidebar links to them.', 'عدد الصفحات التي وُجدت عن طريق خريطة الموقع فقط: %d. لا ترتبط بها أي صفحة ولا قائمة ولا رأس الصفحة ولا تذييلها ولا الشريط الجانبي.'), $orphans_true_total), 'affected_count' => $orphans_true_total, 'shown_in_samples' => count(array_slice($orphans_true, 0, 20)), 'samples' => array_slice($orphans_true, 0, 20), ], I18n::tr('Visitors cannot reach these pages by clicking, and search engines treat them as unimportant because nothing on the site points to them.', 'لا يصل الزائر إلى هذه الصفحات بالتنقل داخل الموقع، وتعاملها محركات البحث كصفحات قليلة الأهمية لأن لا شيء في الموقع يشير إليها.'), [ I18n::tr('Link each page from at least one related page, inside the text.', 'اربط كل صفحة من صفحة واحدة على الأقل ذات صلة، داخل النص نفسه.'), I18n::tr('If a page should stay reachable from anywhere, add it to the menu or the footer.', 'إذا كان يجب الوصول إلى الصفحة من أي مكان فأضفها إلى القائمة أو التذييل.'), I18n::tr('If the page is no longer needed, redirect it to the closest page or remove it.', 'إذا لم تعد الصفحة مطلوبة فحوّلها إلى أقرب صفحة أو احذفها.'), ], [ 'how' => I18n::tr('Run a new audit after adding the links.', 'شغّل فحصًا جديدًا بعد إضافة الروابط.'), 'pass_condition' => I18n::tr('Every page is linked from at least one other page or menu.', 'كل صفحة مرتبطة من صفحة أخرى أو قائمة واحدة على الأقل.'), ], $this->id(), (string) ($orphans_true[0]['url'] ?? '') ); } if ($orphans_commercial_total > 0 && count($url_seen) >= 5) { $shown = array_slice($orphans_commercial, 0, 20); $findings[] = $this->finding( 'crawl_orphan_pages', sprintf(I18n::tr('Pages linked only from the menu, never from inside content (%d)', 'صفحات مرتبطة من القائمة فقط وليس من داخل المحتوى (%d)'), $orphans_commercial_total), 'Crawl', 'low', -3, [ 'source' => 'crawl', 'details' => sprintf(I18n::tr('%d pages are reachable from the menu, header, footer or sidebar, but no article or page links to them inside its text. Legal pages and pages that already redirect are not counted.', 'عدد الصفحات التي يمكن الوصول إليها من القائمة: %d. تظهر في القائمة أو رأس الصفحة أو التذييل أو الشريط الجانبي، لكن لا توجد مقالة أو صفحة تربطها داخل نصها. الصفحات القانونية والصفحات المحوّلة غير محسوبة.'), $orphans_commercial_total), 'affected_count' => $orphans_commercial_total, 'shown_in_samples' => count($shown), 'samples' => $shown, 'legal_pages_excluded_count' => $orphans_legal_total, 'redirecting_pages_excluded_count' => $orphans_redirecting_total, ], I18n::tr('A link inside related text tells search engines what the page is about and passes more value than a repeated menu link. Important pages such as services or product categories rank better when articles link to them.', 'الرابط داخل نص ذي صلة يوضح لمحركات البحث موضوع الصفحة وينقل قيمة أكبر من رابط القائمة المتكرر. الصفحات المهمة مثل الخدمات وتصنيفات المنتجات تترتب أفضل عندما تربطها المقالات.'), [ I18n::tr('For each important page in the list, add a link from two or three related articles, inside the text.', 'لكل صفحة مهمة في القائمة أضف رابطًا من مقالتين أو ثلاث ذات صلة، داخل النص.'), I18n::tr('Skip pages you plan to merge or redirect, and link to the page that will replace them instead.', 'تجاوز الصفحات التي تنوي دمجها أو تحويلها، واربط بدلًا منها بالصفحة التي ستحل محلها.'), I18n::tr('If a page is not important, you can leave it as it is.', 'إذا كانت الصفحة غير مهمة فيمكن تركها كما هي.'), ], [ 'how' => I18n::tr('Run a new audit after adding the links.', 'شغّل فحصًا جديدًا بعد إضافة الروابط.'), 'pass_condition' => I18n::tr('Fewer pages appear in this list.', 'يقل عدد الصفحات في هذه القائمة.'), ], $this->id(), (string) ($shown[0]['url'] ?? '') ); } if ($orphans_legal_total >= 3) { // Don't withhold the info — show the user we noticed legal pages // exist as orphans, but don't penalize. Some users still want to // surface them so they can decide what to do. $findings[] = $this->finding( 'crawl_orphan_legal_pages', sprintf(I18n::tr('Policy pages linked only from the footer (%d), which is normal', 'صفحات السياسات مرتبطة من التذييل فقط (%d)، وهذا طبيعي'), $orphans_legal_total), 'Crawl', 'info', 0, [ 'source' => 'crawl', 'details' => I18n::tr('Privacy, terms, and refund pages are fine with footer links — they do not need contextual links.', 'صفحات الخصوصية والشروط والاسترجاع تكفيها روابط التذييل ولا تحتاج روابط داخل المحتوى.'), 'tier' => 'legal_expected', 'affected_count' => $orphans_legal_total, 'shown_in_samples' => count(array_slice($orphans_legal, 0, 8)), 'sample_limit' => 8, 'samples' => array_slice($orphans_legal, 0, 8), ], I18n::tr('For your information only: policy pages do not need links inside articles.', 'للعلم فقط: صفحات السياسات لا تحتاج روابط داخل المقالات.'), [ I18n::tr('No action needed.', 'لا يلزم أي إجراء.'), ], [ 'how' => I18n::tr('For your information only.', 'للعلم فقط.'), 'pass_condition' => I18n::tr('Nothing to check.', 'لا يوجد ما يُفحص.'), ], $this->id() ); } // ── Internal nofollow links ── if (!empty($nofollow_internal_pages)) { $findings[] = $this->finding( 'crawl_internal_nofollow', I18n::tr('Internal links carrying rel="nofollow"', 'روابط داخلية عليها rel="nofollow"'), 'Crawl', 'low', -2, [ 'source' => 'html', 'details' => I18n::tr('Pages containing nofollow on internal links: ', 'صفحات فيها روابط داخلية عليها nofollow: ') . count($nofollow_internal_pages), 'samples' => array_slice($nofollow_internal_pages, 0, 10), ], I18n::tr('rel="nofollow" on internal links wastes link equity and blocks the flow of ranking signals inside the site.', 'وضع nofollow على الروابط الداخلية يمنع انتقال قيمة الصفحات بين أجزاء موقعك.'), [ I18n::tr('Remove nofollow from internal links in the theme or editor.', 'احذف nofollow من الروابط الداخلية في القالب أو المحرر.'), I18n::tr('Keep nofollow only for untrusted outbound links.', 'استخدم nofollow للروابط الخارجية غير الموثوقة فقط.'), ], [ 'how' => I18n::tr('Re-crawl after the change.', 'أعد الزحف بعد التعديل.'), 'pass_condition' => I18n::tr('No internal links carry nofollow.', 'لا توجد روابط داخلية تحمل nofollow.'), ], $this->id() ); } // ── v1.0.78: Malformed internal URLs ── if (!empty($malformed_urls)) { $findings[] = $this->finding( 'crawl_malformed_internal_urls', sprintf(I18n::tr('Internal links with the domain repeated in the path (%d)', 'روابط داخلية يتكرر فيها اسم النطاق داخل المسار (%d)'), count($malformed_urls)), 'Crawl', 'med', -4, [ 'source' => 'crawl', 'details' => I18n::tr('Internal links where the domain appears twice, e.g. example.com/example.com/page. This usually comes from a theme or editor mistake.', 'روابط داخلية يظهر فيها النطاق مرتين مثل example.com/example.com/page. وهذا غالبًا خطأ في القالب أو في المحرر.'), 'samples' => array_slice($malformed_urls, 0, 10), ], I18n::tr('Malformed links hurt crawl budget and lead to 404 errors. Google may read them as duplicate content or ignore them entirely.', 'الروابط المشوّهة تهدر وقت الزواحف وتنتهي بخطأ 404، وقد يتجاهلها جوجل تمامًا.'), [ I18n::tr('In View Source, search for links where the domain is duplicated.', 'ابحث في مصدر الصفحة عن الروابط التي تكرر فيها النطاق.'), I18n::tr('The usual cause: a relative URL in the template is wrongly resolved to absolute (i.e. href="yoursite.com/page" instead of href="/page").', 'السبب الغالب رابط كُتب بلا بداية صحيحة، مثل href="example.com/page" بدلًا من href="/page".'), I18n::tr('Fix the links in Elementor/the editor to start with / (relative) or https:// (correct absolute).', 'عدّل الروابط في المحرر لتبدأ بـ / أو بـ https:// كاملة.'), I18n::tr('Use a search-and-replace plugin to fix the links in the database in one go.', 'استخدم إضافة للبحث والاستبدال لإصلاح الروابط في قاعدة البيانات دفعة واحدة.'), ], [ 'how' => I18n::tr('Re-crawl after fixing.', 'أعد الزحف بعد الإصلاح.'), 'pass_condition' => I18n::tr('No link repeats the domain in its path.', 'لا يوجد رابط يتكرر فيه اسم النطاق داخل المسار.'), ], $this->id() ); } return $findings; } /** * v3.3.57 — paths that already have an ACTIVE redirect configured in the * two common redirect managers. Returned as a set keyed by the normalized * path (lowercased, decoded, slash-trimmed) for O(1) lookups in the * orphan loop. Read-only, bounded (500 rows per plugin), and silent when * the plugins/tables are absent — including on SQLite test rigs. * * @return array */ private function get_existing_redirect_source_paths(): array { global $wpdb; if (!isset($wpdb) || !is_object($wpdb)) { return []; } $paths = []; $normalize = static function (string $p): string { return Str::lower(trim(rawurldecode($p), "/ \t")); }; try { // Redirection plugin — exact source URLs live in a plain column. $redirection_table = $wpdb->prefix . 'redirection_items'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- read-only existence probe of another plugin's table for audit cross-checking; no caching layer applies. $exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $redirection_table)); if (is_string($exists) && $exists !== '') { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- read-only, bounded read of the Redirection plugin's source URLs so the audit never recommends linking to a page that already 301s away. $rows = $wpdb->get_col($wpdb->prepare('SELECT url FROM %i WHERE status = %s LIMIT 500', $redirection_table, 'enabled')); foreach ((array) $rows as $src) { if (is_string($src) && $src !== '') { $p = $normalize((string) (wp_parse_url($src, PHP_URL_PATH) ?: $src)); if ($p !== '') { $paths[$p] = true; } } } } // Rank Math Redirections — sources are a serialized array of // {pattern, comparison}; only exact comparisons are safe to match. $rank_math_table = $wpdb->prefix . 'rank_math_redirections'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- read-only existence probe of another plugin's table for audit cross-checking; no caching layer applies. $exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $rank_math_table)); if (is_string($exists) && $exists !== '') { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- read-only, bounded read of Rank Math's redirect sources so the audit never recommends linking to a page that already 301s away. $rows = $wpdb->get_col($wpdb->prepare('SELECT sources FROM %i WHERE status = %s LIMIT 500', $rank_math_table, 'active')); foreach ((array) $rows as $serialized) { if (!is_string($serialized) || $serialized === '') { continue; } $sources = function_exists('maybe_unserialize') ? maybe_unserialize($serialized) : null; if (!is_array($sources)) { continue; } foreach ($sources as $src) { if (!is_array($src)) { continue; } $comparison = (string) ($src['comparison'] ?? 'exact'); $pattern = (string) ($src['pattern'] ?? ''); if ($comparison !== 'exact' || $pattern === '') { continue; } $p = $normalize($pattern); if ($p !== '') { $paths[$p] = true; } } } } } catch (\Throwable $_e) { // Cross-check is best-effort: on any storage quirk (SQLite rig, // permission-restricted DB user) fall back to "no exclusions". return $paths; } return $paths; } }