get('robots', []); $status = (int) ($robots['status_code'] ?? 0); $body = (string) ($robots['body_sample'] ?? ''); // 4.0.18: status 0 means the request itself failed; that is reported // once by ProbeCoverageReport as "could not check", not as a defect. if ($status !== 0 && ($status !== 200 || stripos($body, 'user-agent') === false)) { $findings[] = $this->finding( 'seo_robots_missing_or_invalid', I18n::tr('The robots.txt file is missing or unusual', 'ملف robots.txt غير متاح أو غير صالح'), 'Technical SEO', 'high', -8, [ 'source' => 'http', 'details' => sprintf(I18n::tr('robots.txt answered with HTTP status %1$s. User-agent line present: %2$s.', 'أعاد ملف robots.txt رمز الحالة %1$s. يحتوي سطر User-agent: %2$s.'), (string) $status, stripos($body, 'user-agent') !== false ? I18n::tr('yes', 'نعم') : I18n::tr('no', 'لا')), 'snippet' => $ctx->maybe_snippet($body), ], I18n::tr('robots.txt helps Google/Bing understand the crawl rules, and its absence can cause unwanted crawl behavior.', 'robots.txt يساعد Google/Bing على فهم قواعد الزحف، وغيابه قد يسبب سلوك زحف غير مرغوب.'), [ I18n::tr('Make sure /robots.txt returns 200 and contains at least User-agent: *.', 'تأكد أن الرابط /robots.txt يعيد الحالة 200 ويحتوي على سطر User-agent: * على الأقل.'), I18n::tr('Make sure important sections are not blocked by mistake (Disallow).', 'تأكد من عدم منع أقسام مهمة بالخطأ (Disallow).'), ], [ 'how' => I18n::tr('Run a new audit, then open this finding again.', 'شغّل فحصًا جديدًا ثم افتح هذه النتيجة مرة أخرى.'), 'pass_condition' => I18n::tr('robots.txt answers 200 and has a User-agent line.', 'يعيد ملف robots.txt الحالة 200 ويحتوي سطر User-agent.'), ], $this->id() ); } $seed = (array) $ctx->get('seed_info', []); $seed_source = (string) ($seed['source'] ?? ''); $sitemap_url = (string) ($seed['sitemap_url'] ?? ''); $seed_checked = isset($seed['checked']) && is_array($seed['checked']) ? $seed['checked'] : []; $seed_urls_count = isset($seed['urls']) && is_array($seed['urls']) ? count($seed['urls']) : 0; $seed_checked_sitemap_2xx = false; foreach ($seed_checked as $probe) { if (!is_array($probe)) { continue; } $probe_url = (string) ($probe['url'] ?? ''); $probe_status = (int) ($probe['status_code'] ?? 0); if ($probe_status >= 200 && $probe_status < 300 && stripos($probe_url, 'sitemap') !== false) { $seed_checked_sitemap_2xx = true; break; } } $uses_crawl_inventory = in_array($seed_source, ['linked_crawl_inventory', 'latest_crawl_inventory'], true) || (int) ($seed['crawl_run_id'] ?? 0) > 0; $has_sitemap_signal = ($seed_source === 'sitemap') || ($sitemap_url !== '') || $seed_checked_sitemap_2xx; if (!$has_sitemap_signal && !$uses_crawl_inventory) { $findings[] = $this->finding( 'seo_sitemap_not_discovered', I18n::tr('No sitemap was found for the site', 'لم يُعثر على خريطة الموقع (Sitemap)'), 'Technical SEO', 'high', -8, [ 'source' => 'http', 'details' => 'seed_source=' . $seed_source . ' | sitemap_url=' . $sitemap_url . ' | seed_urls=' . $seed_urls_count, 'samples' => [ 'checked' => !empty($seed_checked) ? $seed_checked : [ ['url' => home_url('/wp-sitemap.xml')], ['url' => home_url('/sitemap_index.xml')], ['url' => home_url('/sitemap.xml')], ], ], ], I18n::tr('Sitemap discovery is needed so crawlers and indexing systems find important links quickly.', 'خريطة الموقع تساعد محركات البحث على الوصول إلى صفحاتك المهمة بسرعة.'), [ I18n::tr('Enable the sitemap in your SEO plugin or WordPress\'s core sitemap.', 'فعّل خريطة الموقع من إضافة تحسين محركات البحث أو خريطة ووردبريس الأساسية.'), I18n::tr('Make sure the sitemap URLs return 200 and are not blocked by robots/security rules.', 'تأكد من أن روابط الـ sitemap ترجع 200 وأنها غير محظورة في قواعد robots أو الأمان.'), ], [ 'how' => I18n::tr('Run the audit again and review the sitemap URLs that were checked.', 'شغّل الفحص وراجع روابط sitemap التي تم فحصها.'), 'pass_condition' => I18n::tr('A sitemap is found and opens normally.', 'يتكشّف sitemap_url أو على الأقل رابط sitemap واحد يرجع 200.'), ], $this->id() ); } $pages = (array) $ctx->get('sample_pages', []); $missing_canonical = []; $mismatch_canonical = []; $noindex_pages = []; $redirect_chains = []; $hreflang_count = 0; // v3.3.50 — track HTTP errors separately so a transient Cloudflare 520 // or origin 5xx on a single sample URL doesn't poison noindex/canonical // findings with values that belong to the error page, not the article. $http_error_pages = []; foreach ($pages as $p) { if (!is_array($p)) { continue; } $url = (string) ($p['url'] ?? ''); $canonical = (string) ($p['canonical'] ?? ''); $noindex = !empty($p['robots_noindex']); $chain = isset($p['redirect_chain']) && is_array($p['redirect_chain']) ? $p['redirect_chain'] : []; $norm_url = (string) ($p['normalized_url'] ?? Url::normalize($url)); // v2.8.3 — UrlClassifier shared utility for excluded patterns. if (\AetosSeo\Utils\UrlClassifier::is_excluded($url)) { continue; } // v3.3.50 — gate noindex/canonical/chain collection on a successful // 200 response. Cloudflare error pages (520, 502, …), WAF 403s, and // origin 5xx all legitimately have `noindex, nofollow` and empty // canonical — those values reflect the ERROR page, not the article. // Tracking them as SEO findings produced the round-5 wsend.pro false // positive: one transient 520 emitted a -10 high severity finding. $status_code = (int) ($p['status_code'] ?? 0); $html_parsed = !isset($p['_html_parsed']) || !empty($p['_html_parsed']); if ($status_code !== 0 && $status_code !== 200) { $http_error_pages[] = ['url' => $url, 'status_code' => $status_code]; continue; } if (!$html_parsed) { // Page didn't parse (empty body, malformed HTML) — its '' / false // defaults aren't real SEO signals. continue; } $hreflang_count += (int) ($p['hreflang_count'] ?? 0); if ($canonical === '') { $missing_canonical[] = ['url' => $url]; } else { $norm_can = Url::normalize($canonical); if ($norm_can !== '' && $norm_can !== $norm_url) { $mismatch_canonical[] = ['url' => $url, 'canonical' => $canonical, 'normalized_url' => $norm_url, 'normalized_canonical' => $norm_can]; } } if ($noindex) { $noindex_pages[] = ['url' => $url, 'canonical' => $canonical]; } // v1.0.78: 301→200 is a normal single redirect, NOT a chain. // A real chain is 3+ hops (e.g. 301→301→200 or 301→302→200). if (count($chain) >= 3) { $redirect_chains[] = ['url' => $url, 'chain' => $chain]; } } if (!empty($noindex_pages)) { $findings[] = $this->finding( 'seo_noindex_detected', I18n::tr('Checked pages carrying noindex detected', 'صفحات عليها منع أرشفة (noindex)'), 'Technical SEO', 'high', -10, [ 'source' => 'html', 'details' => I18n::tr('Some checked pages are set to noindex.', 'بعض الصفحات عليها منع أرشفة (noindex).'), 'samples' => array_slice($noindex_pages, 0, 10), ], I18n::tr('noindex on pages that should be indexed can cause them to disappear from Google.', 'منع الأرشفة على صفحات يجب أن تظهر في البحث قد يخفيها من جوجل.'), [ I18n::tr('Check the SEO plugin or theme settings that add meta robots.', 'راجع إعدادات إضافة تحسين محركات البحث أو القالب الذي يضيف منع الأرشفة.'), I18n::tr('Make sure the noindex is not intentional (such as staging pages or internal search results).', 'تأكد أن منع الأرشفة مقصود، مثل صفحات التجارب أو نتائج البحث الداخلي.'), ], [ 'how' => I18n::tr('Run a new audit, then open this finding again.', 'شغّل فحصًا جديدًا ثم افتح هذه النتيجة مرة أخرى.'), 'pass_condition' => I18n::tr('None of the pages you want in search carry noindex.', 'لا يوجد robots_noindex على الصفحات المفحوصة التي تريد فهرستها.'), ], $this->id() ); } if (!empty($missing_canonical)) { $findings[] = $this->finding( 'seo_missing_canonical', I18n::tr('Some pages have no canonical link', 'الرابط الأساسي (canonical) غير موجود في بعض الصفحات'), 'Technical SEO', 'med', -6, [ 'source' => 'html', 'details' => I18n::tr('Missing canonical link tag on checked pages', 'الرابط الأساسي (canonical) غير موجود في الصفحات المفحوصة'), 'samples' => array_slice($missing_canonical, 0, 10), ], I18n::tr('Canonical helps prevent duplication issues and defines the page\'s primary URL.', 'الرابط الأساسي يحدد لجوجل العنوان الصحيح للصفحة ويمنع مشكلات المحتوى المكرر.'), [ I18n::tr('Make sure the theme or SEO plugin adds a canonical to every page.', 'تأكد أن القالب أو إضافة تحسين محركات البحث تضيف الرابط الأساسي لكل صفحة.'), I18n::tr('Review archive, pagination, and parameter pages.', 'راجع صفحات الأرشيف وصفحات الترقيم والروابط التي تحتوي معاملات.'), ], [ 'how' => I18n::tr('Run a new audit, then open this finding again.', 'شغّل فحصًا جديدًا ثم افتح هذه النتيجة مرة أخرى.'), 'pass_condition' => I18n::tr('The checked pages have a canonical link.', 'الصفحات المفحوصة تحتوي الرابط الأساسي.'), ], $this->id() ); } if (!empty($mismatch_canonical)) { $findings[] = $this->finding( 'seo_canonical_mismatch', I18n::tr('Canonical does not match the final URL on some checked pages (estimate)', 'الرابط الأساسي (canonical) لا يطابق عنوان الصفحة في بعض الصفحات (تقدير)'), 'Technical SEO', 'med', -5, [ 'source' => 'html', 'details' => I18n::tr('The canonical link points to a different address than the page itself.', 'يشير الرابط الأساسي (canonical) إلى عنوان غير عنوان الصفحة نفسها.'), 'samples' => array_slice($mismatch_canonical, 0, 10), ], I18n::tr('An incorrect canonical can cause a different URL to be indexed or signals to be lost.', 'الرابط الأساسي الخاطئ قد يجعل جوجل يؤرشف عنوانًا آخر أو يوزع قيمة الصفحة على عنوانين.'), [ I18n::tr('Check the canonical settings in the SEO plugin/theme.', 'راجع إعدادات الرابط الأساسي في إضافة تحسين محركات البحث أو القالب.'), I18n::tr('Check the www/non-www and trailing-slash settings.', 'تأكد من ثبات صيغة العنوان: مع www أو بدونها، وبشرطة مائلة في النهاية أو بدونها.'), ], [ 'how' => I18n::tr('Run a new audit and compare the page address with its canonical link.', 'شغّل فحصًا جديدًا وقارن عنوان الصفحة برابطها الأساسي.'), 'pass_condition' => I18n::tr('The canonical link matches the page address.', 'الرابط الأساسي يطابق عنوان الصفحة.'), ], $this->id() ); } if (!empty($redirect_chains)) { $findings[] = $this->finding( 'seo_redirect_chains', I18n::tr('Redirect chains detected on checked pages', 'سلاسل تحويل متعددة في الصفحات المفحوصة'), 'Technical SEO', 'med', -5, [ 'source' => 'http', 'details' => I18n::tr('Redirect chains of 3 or more hops (a single redirect is not counted).', 'سلاسل تحويل من 3 تحويلات فأكثر (لا يُحسب التحويل الواحد).'), 'samples' => array_slice($redirect_chains, 0, 5), ], I18n::tr('Redirect chains increase load time and consume crawl budget.', 'سلاسل التحويل تبطئ التحميل وتهدر وقت زواحف محركات البحث.'), [ I18n::tr('Reduce the number of redirects (make it a single hop).', 'قلّل عدد التحويلات (اجعلها خطوة واحدة).'), I18n::tr('Unify www/non-www, HTTP->HTTPS, and trailing slash.', 'وحّد صيغة العنوان: مع www أو بدونها، وHTTPS دائمًا، والشرطة المائلة في النهاية.'), ], [ 'how' => I18n::tr('Run a new audit, then open this finding again.', 'شغّل فحصًا جديدًا ثم افتح هذه النتيجة مرة أخرى.'), 'pass_condition' => I18n::tr('Most pages open with one redirect at most.', 'redirect_chain طولها <= 1 لمعظم الصفحات.'), ], $this->id() ); } // v3.3.50 — availability finding. Surfaces non-200 sample pages so the // user sees there was a problem without inheriting noindex/canonical // "issues" that actually belong to the upstream error page. Tiny -1 so // a single transient 520 stays low-priority; if the rerun catches the // same URL as 200 the finding goes away on its own. if (!empty($http_error_pages)) { $codes = array_values(array_unique(array_map(static function ($p) { return (int) $p['status_code']; }, $http_error_pages))); sort($codes); $findings[] = $this->finding( 'sample_page_http_error', I18n::tr('Some checked pages returned an HTTP error (Cloudflare 5xx / origin / WAF)', 'بعض الصفحات المفحوصة أعادت خطأ من الخادم أو من جدار الحماية'), 'Technical SEO', 'info', -1, [ 'source' => 'http', 'details' => sprintf( I18n::tr('Checked pages that answered with an error: %1$d of %2$d (codes: %3$s). Their robots and canonical values were ignored because they belong to the error page.', 'الصفحات المفحوصة التي أعادت خطأ: %1$d من %2$d (الرموز: %3$s). تم تجاهل إعدادات الأرشفة والرابط الأساسي فيها لأنها تخص صفحة الخطأ.'), count($http_error_pages), count($pages), implode(', ', $codes) ), 'samples' => array_slice($http_error_pages, 0, 10), ], I18n::tr('The check ignores robots/canonical values from error pages because they belong to the error page itself, not the article. But if the error rate is high, that signals an unstable origin server or a Cloudflare rule blocking the crawler.', 'لا يعتمد الفحص على بيانات صفحات الخطأ لأنها تخص صفحة الخطأ لا المقال. لكن كثرة الأخطاء تعني أن الخادم غير مستقر أو أن جدار الحماية يحجب زاحف الفحص.'), [ I18n::tr('If it is isolated (a transient Cloudflare 520): ignore it, re-run the audit in an hour.', 'إذا كان الخطأ عابرًا ومعزولًا فتجاهله وأعد الفحص بعد ساعة.'), I18n::tr('If it recurs on the same URLs: review the origin server logs and Cloudflare > Analytics > Errors.', 'وإذا تكرر على الصفحات نفسها فراجع سجل أخطاء الخادم أو لوحة جدار الحماية لديك.'), I18n::tr('If 403/429: review WAF/Bot-Fight Mode, it may be blocking the audit\'s user-agent.', 'إذا ظهر الرمز 403 أو 429 فراجع إعدادات جدار الحماية أو حماية الزواحف، فقد تحجب زاحف الفحص.'), ], [ 'how' => I18n::tr('Run a new audit, then open this finding again.', 'شغّل فحصًا جديدًا ثم افتح هذه النتيجة مرة أخرى.'), 'pass_condition' => I18n::tr('All checked pages return 200 (or 301→200 within a short chain).', 'كل صفحات العينة ترجع 200 (أو 301→200 ضمن سلسلة قصيرة).'), ], $this->id() ); } // Broken internal links (limited). $settings = $ctx->settings(); $per_page = (int) ($settings['scan_limits']['broken_links_per_page'] ?? 15); $per_page = max(1, min(200, $per_page)); // Total cap (protect hosting): per_page * number of sampled pages (capped). $max_checks = min(200, $per_page * max(1, min(20, count($pages)))); // Optional legacy override (manual settings). if (isset($settings['scan_limits']['max_internal_link_checks'])) { $max_checks = (int) $settings['scan_limits']['max_internal_link_checks']; } $max_checks = max(10, min(200, $max_checks)); $to_check = []; foreach ($pages as $p) { if (!is_array($p)) { continue; } $src = (string) ($p['url'] ?? ''); $links = isset($p['internal_links']) && is_array($p['internal_links']) ? $p['internal_links'] : []; $count_this_page = 0; foreach ($links as $lnk) { if (!is_string($lnk) || $lnk === '') { continue; } if (!Url::is_internal_url($lnk)) { continue; } $key = Url::normalize($lnk); if ($key === '') { continue; } // Count per-page even if the URL was already added from another page. $count_this_page++; if ($count_this_page > $per_page) { break; } if (!isset($to_check[$key])) { $to_check[$key] = ['url' => $lnk, 'found_on' => $src]; } if (count($to_check) >= $max_checks) { break 2; } } } $broken = []; // v3.3.55 — wall-clock budget for the live broken-link probe. Each // wp_remote_head can take up to its timeout, and a large site can queue // up to $max_checks (200) links here. Without a deadline this single // loop ran for minutes (200 links × up to 8s ≈ 26 min worst case; ~5 min // typical through a CDN), which pushed the whole checks phase past the // host's hard request limit and stalled the audit at "technical_seo_check // (2/32)" until a watchdog reaped it as "failed". We now cap the probe at // a fixed wall-clock window (and a tighter per-request timeout) and // report how many links were actually checked. $link_budget = 20.0; if (isset($settings['scan_limits']['broken_link_check_seconds']) && is_numeric($settings['scan_limits']['broken_link_check_seconds'])) { $link_budget = (float) $settings['scan_limits']['broken_link_check_seconds']; } $link_budget = max(5.0, min(60.0, $link_budget)); // 4.0.18: probe once per run; the second language pass reuses it. $probe = $ctx->memo('technical_seo.internal_link_probe', function () use ($to_check, $link_budget): array { return $this->probe_internal_links($to_check, $link_budget); }); $broken = (array) ($probe['broken'] ?? []); $redirects = (array) ($probe['redirects'] ?? []); if (!empty($broken)) { $findings[] = $this->finding( 'seo_broken_internal_links', I18n::tr('Broken internal links detected (limited sample)', 'تم رصد روابط داخلية مكسورة (عينة محدودة)'), 'Technical SEO', 'med', -5, [ 'source' => 'crawl', 'details' => sprintf(I18n::tr('Internal links checked from sample pages (up to %1$d); broken: %2$d', 'الروابط الداخلية المفحوصة من صفحات العينة (حتى %1$d)، والمعطّل منها: %2$d'), (int) $max_checks, count($broken)), 'samples' => Redactor::redact_array($broken), ], I18n::tr('Broken links hurt UX, consume crawl budget, and can reduce trust.', 'الروابط المكسورة تضر UX وتستهلك الزحف وقد تقلل الثقة.'), [ I18n::tr('Fix the links or update the permalinks if they changed.', 'أصلح الروابط، أو حدّث روابط الصفحات إن كانت قد تغيرت.'), I18n::tr('Replace 404 links with valid links or add a suitable redirect.', 'استبدل الروابط التي تعيد 404 بروابط صحيحة، أو أضف تحويلًا مناسبًا.'), ], [ 'how' => I18n::tr('Run a new audit, then open this finding again.', 'شغّل فحصًا جديدًا ثم افتح هذه النتيجة مرة أخرى.'), 'pass_condition' => I18n::tr('No broken internal links in the sample.', 'لا توجد روابط داخلية مكسورة في العينة.'), ], $this->id() ); } // 4.0.18: internal links that answer with a redirect. Each one costs // the visitor and the crawler an extra request; the link should point // straight at the final address. if (!empty($redirects)) { $findings[] = $this->finding( 'seo_internal_links_redirect', I18n::tr('Internal links point to redirected addresses', 'روابط داخلية تشير إلى عناوين محوّلة'), 'Technical SEO', 'low', -2, [ 'source' => 'crawl', 'details' => sprintf(I18n::tr('Internal links that answer with a redirect instead of the final page: %d.', 'عدد الروابط الداخلية التي تعيد تحويلًا بدل الصفحة النهائية: %d.'), count($redirects)), 'samples' => Redactor::redact_array($redirects), ], I18n::tr('A link that goes through a redirect makes every visit and every crawl take an extra step. Linking straight to the final address is faster and keeps link value intact.', 'الرابط الذي يمر عبر تحويل يضيف خطوة إضافية لكل زيارة ولكل زحف. الربط المباشر بالعنوان النهائي أسرع ويحافظ على قيمة الرابط.'), [ I18n::tr('Open the page listed under "found on" and edit the link.', 'افتح الصفحة المذكورة في خانة «موجود في» وعدّل الرابط.'), I18n::tr('Replace the old address with the final address shown as the target.', 'استبدل العنوان القديم بالعنوان النهائي الظاهر في خانة الوجهة.'), ], [ 'how' => I18n::tr('Run a new audit after updating the links.', 'شغّل فحصًا جديدًا بعد تعديل الروابط.'), 'pass_condition' => I18n::tr('No checked internal link answers with a redirect.', 'لا يعيد أي رابط داخلي مفحوص تحويلًا.'), ], $this->id(), (string) ($redirects[0]['found_on'] ?? '') ); } // 4.0.18: category and tag archives with no posts. WordPress still // serves them as pages, so they are thin pages that can be indexed. $empty_terms = (array) (((array) $ctx->get('wp', []))['empty_terms'] ?? []); if (!empty($empty_terms)) { $findings[] = $this->finding( 'seo_empty_term_archives', I18n::tr('Empty category or tag pages', 'صفحات تصنيفات أو وسوم فارغة'), 'Technical SEO', 'low', -2, [ 'source' => 'wp', 'details' => sprintf(I18n::tr('Categories or tags with no published posts but a public page: %d.', 'عدد التصنيفات أو الوسوم التي بلا مقالات منشورة ولها صفحة عامة: %d.'), count($empty_terms)), 'samples' => array_slice($empty_terms, 0, 20), ], I18n::tr('An empty archive page shows visitors and search engines a page with nothing on it. If it gets indexed it adds a thin page to the site.', 'صفحة الأرشيف الفارغة تعرض للزائر ولمحركات البحث صفحة بلا محتوى، وإذا أُرشفت أضافت صفحة ضعيفة للموقع.'), [ I18n::tr('Add posts to the category or tag if you plan to use it.', 'أضف مقالات إلى التصنيف أو الوسم إذا كنت ستستخدمه.'), I18n::tr('Otherwise delete it, or merge it into a related category.', 'وإلا فاحذفه أو ادمجه في تصنيف قريب.'), ], [ 'how' => I18n::tr('Run a new audit after cleaning up the empty terms.', 'شغّل فحصًا جديدًا بعد معالجة التصنيفات الفارغة.'), 'pass_condition' => I18n::tr('No public category or tag is empty.', 'لا يوجد تصنيف أو وسم عام فارغ.'), ], $this->id(), (string) ($empty_terms[0]['url'] ?? '') ); } // v1.0.58: hreflang only matters for multi-language sites. Detect if // multi-language signals exist (WPML, Polylang, TranslatePress plugins, // OR multiple lang values across pages) before raising the finding. // On a mono-language site (e.g. an Arabic-only site), this finding was // pure noise. $multi_lang_signals = false; $wp_ctx = (array) $ctx->get('wp', []); $active_plugins = isset($wp_ctx['plugins']) && is_array($wp_ctx['plugins']) ? $wp_ctx['plugins'] : []; $multilang_plugins = ['sitepress-multilingual-cms/sitepress.php', 'polylang/polylang.php', 'polylang-pro/polylang.php', 'translatepress-multilingual/index.php', 'weglot/weglot.php', 'gtranslate/gtranslate.php']; foreach ($active_plugins as $pl) { if (is_array($pl) && in_array((string) ($pl['file'] ?? ''), $multilang_plugins, true)) { $multi_lang_signals = true; break; } } if (!$multi_lang_signals) { // Check if pages have different lang values (multi-lang site without WPML) $langs_seen = []; foreach ($pages as $pp) { if (is_array($pp) && !empty($pp['lang'])) { $langs_seen[(string) $pp['lang']] = true; } } if (count($langs_seen) > 1) { $multi_lang_signals = true; } } if ($hreflang_count === 0 && count($pages) > 0 && $multi_lang_signals) { $findings[] = $this->finding( 'seo_hreflang_not_detected', I18n::tr('hreflang not detected despite multilingual site signals', 'لا توجد وسوم اللغات البديلة (hreflang) رغم أن الموقع متعدد اللغات'), 'Technical SEO', 'med', -3, [ 'source' => 'html', 'details' => I18n::tr('No hreflang tags on the checked pages, although the site looks multilingual.', 'لا توجد وسوم hreflang في الصفحات رغم أن الموقع يبدو متعدد اللغات.'), ], I18n::tr('The site is multilingual but has no hreflang. The result: Google may show the English version to an Arabic user and vice versa. Very important for international SEO.', 'الموقع متعدد اللغات لكن بلا وسوم اللغات البديلة، فقد يعرض جوجل النسخة الإنجليزية للزائر العربي والعكس.'), [ I18n::tr('If running WPML: WPML → Languages → Languages URL Format → make sure "Add hreflang" is enabled.', 'إذا كنت تستخدم إضافة ترجمة ففعّل خيار إضافة وسوم hreflang في إعدادات روابط اللغات.'), I18n::tr('If Polylang: it usually adds hreflang automatically, check Settings → URL modifications.', 'أغلب إضافات الترجمة تضيف هذه الوسوم تلقائيًا، فراجع إعدادات الروابط فيها.'), I18n::tr('If the theme handles this manually: review the template files to confirm the hreflang alternate tags are inside the head.', 'إذا كان القالب يدير اللغات يدويًا فتأكد من وجود وسوم hreflang في رأس كل صفحة.'), ], [ 'how' => I18n::tr('Run the audit after adding hreflang.', 'شغّل فحصًا جديدًا بعد إضافة الوسوم.'), 'pass_condition' => I18n::tr('At least one checked page has hreflang tags.', 'hreflang_count > 0 على الأقل في صفحة عينة واحدة.'), ], $this->id() ); } // ── Open Graph tags ──────────────────────────────────────────────────────── $og_missing = []; $lang_missing = []; // v3.3.46 — count only pages whose HTML actually parsed (status 200 + // successful DOM load). The lang ratio is taken against this number, // not count($pages), so a degraded crawl can't manufacture a false // "lang attribute missing" finding out of '' defaults. $lang_pages_parsed = 0; $no_favicon = false; $favicon_checked = false; $tw_missing = []; $xrobots_noindex = []; $no_last_modified = []; foreach ($pages as $p) { if (!is_array($p)) { continue; } $purl = (string) ($p['url'] ?? ''); $og_title = (string) ($p['og_title'] ?? ''); $og_image = (string) ($p['og_image'] ?? ''); $tw_card = (string) ($p['twitter_card'] ?? ''); $lang_val = (string) ($p['lang'] ?? ''); // 4.0.17: social tags and favicon are HTML signals; a redirect or // error row has empty defaults and must not count as "missing". $html_ok = $this->is_parsed_html_page($p); if ($html_ok && ($og_title === '' || $og_image === '')) { $og_missing[] = [ 'url' => $purl, 'og_title' => $og_title !== '' ? 'ok' : 'missing', 'og_image' => $og_image !== '' ? 'ok' : 'missing', ]; } if ($html_ok && $tw_card === '') { $tw_missing[] = ['url' => $purl]; } // v3.3.46 — only judge lang on pages that genuinely parsed. // A failed crawl returns lang='' as a default, which must not be // mistaken for a missing attribute. $page_parsed = (!isset($p['_html_parsed']) || !empty($p['_html_parsed'])) && (int) ($p['status_code'] ?? 200) === 200; if ($page_parsed) { $lang_pages_parsed++; if ($lang_val === '') { $lang_missing[] = ['url' => $purl]; } } // Check favicon once (from the first page) if (!$favicon_checked && $html_ok) { $favicon_checked = true; $no_favicon = empty($p['has_favicon']); } // ── X-Robots-Tag header ── $xrobots_header = (string) ($p['x_robots_tag'] ?? ''); if ($xrobots_header !== '' && stripos($xrobots_header, 'noindex') !== false) { $xrobots_noindex[] = ['url' => $purl, 'x_robots_tag' => $xrobots_header]; } // ── Last-Modified header ── // v2.1.3 — only flag pages that have NO freshness signal AT ALL. // WordPress dynamic pages under no-cache/session responses // typically don't emit Last-Modified, but freshness can still be // proven via article_modified_time (JSON-LD/OG) or sitemap // lastmod. If any of these alternatives is present, the page is // not actually "missing freshness signal" — it's just missing // the HTTP header form of it. $last_mod = (string) ($p['last_modified'] ?? ''); $article_modified = (string) ($p['article_modified_time'] ?? ''); if ($html_ok && $last_mod === '' && $article_modified === '') { $no_last_modified[] = ['url' => $purl]; } } $og_ratio = count($pages) > 0 ? count($og_missing) / count($pages) : 0; if ($og_ratio > 0.4 && count($pages) > 1) { $findings[] = $this->finding( 'seo_og_tags_missing', I18n::tr('Most pages have no share title or share image', 'أغلب الصفحات بلا عنوان أو صورة للمشاركة (Open Graph)'), 'Technical SEO', 'med', -4, [ 'source' => 'html', 'details' => sprintf(I18n::tr('%.0f%% of checked pages have no share title or share image (og:title or og:image).', '%.0f%% من الصفحات بلا عنوان أو صورة للمشاركة (og:title أو og:image).'), $og_ratio * 100), 'samples' => array_slice($og_missing, 0, 8), ], I18n::tr('OG tags are essential for social sharing and for AI tools that gather site information.', 'عنوان المشاركة وصورتها يحددان شكل رابطك في واتساب ومواقع التواصل، وتعتمد عليهما أدوات الذكاء الاصطناعي أيضًا.'), [ I18n::tr('Enable Open Graph in Yoast or RankMath (Social → Facebook).', 'فعّل بيانات المشاركة (Open Graph) من إعدادات المشاركة الاجتماعية في إضافة تحسين محركات البحث.'), I18n::tr('Set a default OG image of at least 1200×630 pixels.', 'حدد صورة OG افتراضية بأبعاد 1200×630 بكسل على الأقل.'), I18n::tr('Check: https://developers.facebook.com/tools/debug/', 'للتحقق: https://developers.facebook.com/tools/debug/'), ], [ 'how' => I18n::tr('Run the audit after enabling OG.', 'شغّل فحصًا جديدًا بعد التفعيل.'), 'pass_condition' => I18n::tr('og:title and og:image present on most pages.', 'og:title وog:image موجودان في أغلب الصفحات.'), ], $this->id() ); } $tw_ratio = count($pages) > 0 ? count($tw_missing) / count($pages) : 0; if ($tw_ratio > 0.6 && count($pages) > 1) { $findings[] = $this->finding( 'seo_twitter_card_missing', I18n::tr('Most pages have no X (Twitter) share card', 'أغلب الصفحات بلا بطاقة مشاركة لمنصة إكس (Twitter Card)'), 'Technical SEO', 'info', -1, [ 'source' => 'html', 'details' => sprintf(I18n::tr('%.0f%% of the checked pages have no X (Twitter) share card (twitter:card).', '%.0f%% من الصفحات المفحوصة بلا بطاقة مشاركة لمنصة إكس (twitter:card).'), $tw_ratio * 100), 'samples' => array_slice($tw_missing, 0, 5), ], I18n::tr('Twitter Cards ensure the site appears properly when shared on X/Twitter.', 'بطاقة المشاركة تضمن ظهور روابطك بشكل مناسب عند مشاركتها في منصة إكس.'), [ I18n::tr('Enable Twitter Cards in your SEO plugin settings (Social → Twitter).', 'فعّل بطاقات منصة إكس من إعدادات المشاركة الاجتماعية في إضافة تحسين محركات البحث.'), I18n::tr('Use summary_large_image for the best appearance.', 'استخدم summary_large_image لأفضل ظهور.'), ], [ 'how' => I18n::tr('Run the audit after enabling Twitter Cards.', 'شغّل فحصًا جديدًا بعد التفعيل.'), 'pass_condition' => I18n::tr('Most pages have an X (Twitter) card.', 'أغلب الصفحات تحتوي بطاقة مشاركة لمنصة إكس.'), ], $this->id() ); } // v3.3.46 — ratio + gate use the parsed-page count. When zero pages // parsed (degraded crawl) the check stays silent instead of emitting // a false "lang missing" finding. $lang_ratio = $lang_pages_parsed > 0 ? count($lang_missing) / $lang_pages_parsed : 0; if ($lang_ratio > 0.5 && $lang_pages_parsed > 1) { $findings[] = $this->finding( 'seo_html_lang_missing', I18n::tr('lang attribute missing on the element', 'لغة الصفحة غير محددة في وسم html'), 'Technical SEO', 'med', -3, [ 'source' => 'html', 'details' => sprintf(I18n::tr('%.0f%% of checked pages set no language on the html tag.', '%.0f%% من الصفحات المفحوصة لا تحدد لغة الصفحة في وسم html.'), $lang_ratio * 100), 'samples' => array_slice($lang_missing, 0, 8), ], I18n::tr('The lang attribute tells search engines and browsers the content language, improves accessibility, and helps Google rank correctly.', 'تحديد اللغة يخبر محركات البحث والمتصفحات بلغة المحتوى، ويساعد قارئات الشاشة، ويدعم ظهور الصفحة للجمهور الصحيح.'), [ I18n::tr('In WordPress: the theme usually adds lang automatically. Check functions.php.', 'في ووردبريس يضيف القالب اللغة عادة تلقائيًا، فإن لم يفعل فراجع القالب.'), I18n::tr('In Elementor/Divi: make sure the theme supports the lang attribute.', 'في Elementor/ديفي: تأكد أن القالب يدعم خاصية lang.'), I18n::tr('It should be: for Arabic sites or for Saudi Arabia.', 'يجب أن يكون الوسم مثل للمواقع العربية أو للسعودية.'), ], [ 'how' => I18n::tr('Run the audit after fixing the theme.', 'شغّل فحصًا جديدًا بعد إصلاح القالب.'), 'pass_condition' => I18n::tr('Most pages set a language on the html tag.', 'أغلب الصفحات تحدد لغتها في وسم html.'), ], $this->id() ); } if ($no_favicon && $favicon_checked) { $findings[] = $this->finding( 'seo_favicon_missing', I18n::tr('The site icon (favicon) is missing', 'أيقونة الموقع (Favicon) غير موجودة'), 'Technical SEO', 'info', -1, [ 'source' => 'html', 'details' => I18n::tr('No favicon link tag (rel icon / shortcut icon) detected', 'لم يُعثر على وسم أيقونة الموقع (Favicon)'), ], I18n::tr('The favicon appears in browser tabs and search results and strengthens brand recognition.', 'أيقونة الموقع تظهر في تبويبات المتصفح ونتائج البحث وتساعد الزائر على تمييز علامتك.'), [ I18n::tr('In WordPress: add a Site Icon from Appearance → Customize → Site Identity.', 'في ووردبريس أضف أيقونة الموقع من المظهر ثم تخصيص ثم هوية الموقع.'), I18n::tr('Recommended size: 512×512 PNG.', 'حجم موصى: 512×512 PNG.'), ], [ 'how' => I18n::tr('Run the audit after adding a favicon.', 'شغّل فحصًا جديدًا بعد إضافة الأيقونة.'), 'pass_condition' => I18n::tr('The page head has a site icon link.', 'يحتوي رأس الصفحة على رابط أيقونة الموقع.'), ], $this->id() ); } // ── X-Robots-Tag: noindex in HTTP header ── if (!empty($xrobots_noindex)) { $findings[] = $this->finding( 'seo_xrobots_noindex_header', I18n::tr('X-Robots-Tag: noindex detected in HTTP headers', 'ترويسة الخادم تمنع الأرشفة (X-Robots-Tag: noindex)'), 'Technical SEO', 'high', -8, [ 'source' => 'http', 'details' => I18n::tr('X-Robots-Tag header contains noindex — may override meta robots', 'ترويسة X-Robots-Tag تحتوي noindex، وهي تتقدم على إعدادات الصفحة'), 'samples' => array_slice($xrobots_noindex, 0, 10), ], I18n::tr('X-Robots-Tag in an HTTP header overrides meta robots. Your pages may be blocked from indexing without your knowledge.', 'هذه الترويسة يرسلها الخادم وتتقدم على إعدادات الأرشفة داخل الصفحة، فقد تكون صفحاتك محجوبة عن البحث دون علمك.'), [ I18n::tr('Check the server settings (Apache/Nginx) and any security plugins that may add this header.', 'تحقق من إعدادات السيرفر (Apache/Nginx) ومن إضافات الأمان التي قد تضيف هذا الـheader.'), I18n::tr('Remove X-Robots-Tag: noindex from .htaccess or the Nginx config for the pages you want indexed.', 'احذف X-Robots-Tag: noindex من إعدادات الخادم للصفحات التي يجب أن تظهر في البحث.'), ], [ 'how' => I18n::tr('Run the audit after removing the header.', 'شغّل فحصًا جديدًا بعد حذف الترويسة.'), 'pass_condition' => I18n::tr('No important page sends X-Robots-Tag: noindex.', 'لا ترسل أي صفحة مهمة ترويسة منع الأرشفة.'), ], $this->id() ); } // ── Missing Last-Modified header ── // v2.1.3 — demoted to info/0 (was info/-1). WordPress dynamic pages // legitimately omit Last-Modified under no-cache/session responses; // article_modified_time + sitemap lastmod are valid alternatives. // Only flag when BOTH the HTTP header AND article_modified_time are // absent (the per-page filter above now requires both). $lm_ratio = count($pages) > 0 ? count($no_last_modified) / count($pages) : 0; if ($lm_ratio > 0.7 && count($pages) > 2) { $findings[] = $this->finding( 'seo_missing_last_modified', I18n::tr('Last-Modified + dateModified missing on most pages', 'أغلب الصفحات بلا تاريخ تحديث ظاهر (Last-Modified أو dateModified)'), 'Technical SEO', 'info', 0, [ 'source' => 'http', 'details' => sprintf(I18n::tr('%.0f%% of checked pages send neither a Last-Modified header nor an article update date. A lastmod date in the sitemap still counts as a freshness signal.', '%.0f%% من الصفحات لا ترسل ترويسة Last-Modified ولا تاريخ تحديث للمقال. ويبقى تاريخ lastmod في خريطة الموقع إشارة تحديث صالحة.'), $lm_ratio * 100), 'tier' => 'parked_recommendation', 'samples' => array_slice($no_last_modified, 0, 8), ], I18n::tr('For your information. WordPress pages usually do not send a Last-Modified header, and that is not a defect when the update date is in the article data or the sitemap. This costs no points.', 'ملاحظة للعلم. صفحات ووردبريس لا ترسل عادة ترويسة Last-Modified، وهذا ليس عيبًا إذا كان تاريخ التحديث موجودًا في بيانات المقال أو في خريطة الموقع. لا يخصم هذا نقاطًا.'), [ I18n::tr('Optional: enable Last-Modified in WP Rocket → Static Pages.', 'اختياري: فعّل إرسال ترويسة Last-Modified من إضافة الأداء إن كانت تدعم ذلك.'), I18n::tr('Make sure the Article/BlogPosting schema includes dateModified.', 'تأكد أن بيانات المقال تحتوي تاريخ التحديث (dateModified).'), I18n::tr('Make sure sitemap.xml includes a lastmod element for each URL.', 'تأكد أن خريطة الموقع تحتوي تاريخ التحديث (lastmod) لكل رابط.'), I18n::tr('If all these ALT signals are present, ignore this finding.', 'إذا كانت هذه الإشارات البديلة كلها موجودة فتجاهل هذه النتيجة.'), ], [ 'how' => I18n::tr('Optional — if you want to add a Last-Modified header, run a new scan.', 'اختياري: إذا أضفت الترويسة فشغّل فحصًا جديدًا.'), 'pass_condition' => I18n::tr('Nothing to check.', 'لا يوجد ما يُفحص.'), ], $this->id() ); } return $findings; } /** * Live probe of internal links found on sample pages. 4.0.18: moved out * of run() so the audit can memoize it per run, and it now also reports * internal links that answer with a redirect. * * @param array> $to_check * @return array{broken:array>,redirects:array>,checked:int} */ private function probe_internal_links(array $to_check, float $link_budget): array { $broken = []; $redirects = []; $checked = 0; $link_deadline = microtime(true) + $link_budget; foreach ($to_check as $item) { if (microtime(true) >= $link_deadline) { break; } $link = (string) ($item['url'] ?? ''); $checked++; $head = wp_remote_head($link, [ 'timeout' => 5, // Same-site guarantee: do not auto-follow redirects (which could // leave this site). 'redirection' => 0, 'user-agent' => \AetosSeo\Utils\Http::user_agent(), // First-party internal audit request marker. 'aetos_seo' => 1, ]); if (is_wp_error($head)) { $broken[] = ['url' => $link, 'found_on' => (string) ($item['found_on'] ?? ''), 'error' => $head->get_error_message()]; continue; } $code = (int) wp_remote_retrieve_response_code($head); if ($code === 405 || $code === 403) { $fallback = wp_remote_get($link, [ 'timeout' => 5, 'limit_response_size' => 1024, 'redirection' => 0, 'user-agent' => \AetosSeo\Utils\Http::user_agent(), 'aetos_seo' => 1, ]); if (!is_wp_error($fallback)) { $head = $fallback; $code = (int) wp_remote_retrieve_response_code($fallback); } } if ($code >= 400 || $code === 0) { $broken[] = ['url' => $link, 'found_on' => (string) ($item['found_on'] ?? ''), 'status' => $code]; } elseif ($code >= 300 && $code < 400 && count($redirects) < 20) { $redirects[] = [ 'url' => $link, 'found_on' => (string) ($item['found_on'] ?? ''), 'status' => $code, 'target' => (string) wp_remote_retrieve_header($head, 'location'), ]; } if (count($broken) >= 20) { break; } } return ['broken' => $broken, 'redirects' => $redirects, 'checked' => $checked]; } }