get('sample_pages', []); // v3.3.52 P2.4 — schema quality is only meaningful on real, indexable, // parsed pages. Error / redirect / noindex / unparsed rows are excluded // so a transient 4xx or a noindex utility page never produces a false // "schema missing" finding. $content_pages = $this->indexable_parsed_sample_pages($pages); $missing_required_props = []; $product_pages_no_schema = []; $product_incomplete = []; $localbusiness_incomplete = []; $review_no_rating = []; $breadcrumb_invalid = []; $logo_url = ''; $home_url_norm = home_url(); // New tracking arrays $article_missing_fields = []; $faq_no_answers = []; $video_incomplete = []; $no_schema_at_all = []; $any_jsonld_truncated = false; // v3.3.52 P2.3 — any page whose JSON-LD was capped. foreach ($content_pages as $p) { if (!is_array($p)) { continue; } if (!empty($p['jsonld_raw_truncated'])) { $any_jsonld_truncated = true; } $url = (string) ($p['url'] ?? ''); $types = isset($p['jsonld_types']) && is_array($p['jsonld_types']) ? $p['jsonld_types'] : []; $raw_blocks = isset($p['jsonld_raw']) && is_array($p['jsonld_raw']) ? $p['jsonld_raw'] : []; // Check for Organization/Website on homepage $is_home = (strpos($url, $home_url_norm) === 0 && strlen($url) - strlen($home_url_norm) <= 1); if ($is_home && !in_array('Organization', $types, true) && !in_array('WebSite', $types, true)) { $missing_required_props[] = [ 'url' => $url, 'missing' => I18n::tr('Organization or WebSite structured data on the homepage', 'بيانات منظمة من نوع Organization أو WebSite على الصفحة الرئيسية'), ]; } // ── Product detection ── // v3.3.52 P1.2 — only SINGLE product pages should demand Product // schema. Listing / category / shop-archive pages (CollectionPage / // ItemList) need CollectionPage or ItemList, NOT Product — flagging // them as "Product schema missing" was a high-severity false // positive on every WooCommerce/Shopify catalog. $looks_like_product = $this->url_looks_like_single_product($url); $has_product_schema = false; $is_listing_schema = false; foreach ($types as $t) { $tl = strtolower((string) $t); if ($tl === 'product') { $has_product_schema = true; } if (in_array($tl, ['collectionpage', 'itemlist', 'searchresultspage', 'offercatalog'], true)) { $is_listing_schema = true; } } if ($looks_like_product && !$has_product_schema && !$is_listing_schema) { $product_pages_no_schema[] = ['url' => $url]; } // v1.0.58: pre-scan flattened items for Person entities so we can // recognize Article→@id author references. Yoast/Rank Math emit // Article with `"author": {"@id": "https://site.com/#/schema/person/abc"}` // and a separate `Person` entity in the same @graph. The naive check // `empty($item['author'])` was missing this pattern. // // v1.0.59: also pre-scan for ImageObject entities so we can // recognize Article→@id image references the same way. $page_has_person_entity = false; $page_has_image_entity = false; foreach ($raw_blocks as $block_pre) { if (!is_array($block_pre)) { continue; } foreach ($this->flatten_jsonld($block_pre) as $pre_item) { $pre_type = $this->normalize_type($pre_item['@type'] ?? ''); if ($pre_type === 'person' && !empty($pre_item['name'])) { $page_has_person_entity = true; } if ($pre_type === 'imageobject' && (!empty($pre_item['url']) || !empty($pre_item['contentUrl']))) { $page_has_image_entity = true; } if ($page_has_person_entity && $page_has_image_entity) { break 2; } } } // v2.0.1: pre-resolve LocalBusiness entities via SchemaResolver so // the completeness check sees fields linked through @id refs (e.g. // organization.location -> #place exposes the Place's geo/address). $resolved_local = \AetosSeo\Audit\SchemaResolver::resolve_local_business_entities($raw_blocks); foreach ($resolved_local as $resolved) { $missing = []; if (empty($resolved['address'])) $missing[] = 'address'; if (empty($resolved['telephone']) && empty($resolved['contactPoint'])) $missing[] = 'telephone'; if (empty($resolved['openingHours']) && empty($resolved['openingHoursSpecification'])) $missing[] = 'openingHours'; if (empty($resolved['geo']) && empty($resolved['hasMap'])) $missing[] = 'geo'; if (!empty($missing)) { $localbusiness_incomplete[] = ['url' => $url, 'missing' => $missing]; } } // v2.3.0 — augment items with SchemaResolver-resolved entities. // Catches Article/Product/Review nodes that flatten_jsonld misses // (deep @graph nesting, @id-resolved offers/author/image refs). $resolver_items = []; try { $by_id = \AetosSeo\Audit\SchemaResolver::build_entity_index($raw_blocks); foreach ($raw_blocks as $rb) { if (!is_array($rb)) { $rb = is_string($rb) ? json_decode($rb, true) : null; if (!is_array($rb)) continue; } foreach (\AetosSeo\Audit\SchemaResolver::flatten_graph($rb) as $node) { $resolver_items[] = \AetosSeo\Audit\SchemaResolver::resolve_entity($node, $by_id); } } } catch (\Throwable $_e) { // ignore — fall back to flatten_jsonld only. } // Walk raw blocks for the remaining (non-LocalBusiness) checks. foreach ($raw_blocks as $block) { if (!is_array($block)) { continue; } $items = array_merge($this->flatten_jsonld($block), $resolver_items); foreach ($items as $item) { $type = $this->normalize_type($item['@type'] ?? ''); // Product completeness if ($type === 'product') { $missing = []; if (empty($item['name'])) $missing[] = 'name'; if (empty($item['image'])) $missing[] = 'image'; if (empty($item['offers'])) $missing[] = 'offers'; if (!empty($missing)) { $product_incomplete[] = ['url' => $url, 'missing' => $missing]; } } // Review without rating if ($type === 'review' || $type === 'aggregaterating') { if ($type === 'review' && empty($item['reviewRating'])) { $review_no_rating[] = ['url' => $url, 'missing' => 'reviewRating']; } if ($type === 'aggregaterating' && (empty($item['ratingValue']) || empty($item['reviewCount']))) { $review_no_rating[] = ['url' => $url, 'missing' => 'ratingValue/reviewCount']; } } // BreadcrumbList — must have itemListElement if ($type === 'breadcrumblist') { $items_list = isset($item['itemListElement']) && is_array($item['itemListElement']) ? $item['itemListElement'] : []; if (count($items_list) < 2) { $breadcrumb_invalid[] = ['url' => $url, 'reason' => 'itemListElement < 2 items']; } } // Capture logo URL from Organization if ($logo_url === '' && in_array($type, ['organization', 'localbusiness'], true)) { if (!empty($item['logo'])) { $logo_url = is_array($item['logo']) ? (string) ($item['logo']['url'] ?? '') : (string) $item['logo']; } } // Article / BlogPosting / NewsArticle completeness if (in_array($type, ['article', 'blogposting', 'newsarticle'], true)) { $miss = []; // v1.0.59: accept `name` as headline alias (schema.org // treats them as equivalent for Article-family). Yoast // and Rank Math both emit `name` for BlogPosting, so // requiring `headline` strictly produced false positives. $has_headline = !empty($item['headline']) || !empty($item['name']); if (!$has_headline) { $miss[] = 'headline (or name)'; } // v1.0.58: accept author via @id reference / Person entity in @graph $has_inline_author = !empty($item['author']); if (!$has_inline_author && !$page_has_person_entity) { $miss[] = 'author'; } if (empty($item['datePublished'])) { $miss[] = 'datePublished'; } // v1.0.59: accept image via: // - inline `image` field (string, object, or @id ref) // - inline `primaryImageOfPage` field // - separate ImageObject entity in the @graph (page-level) $has_image = !empty($item['image']) || !empty($item['primaryImageOfPage']) || !empty($item['thumbnailUrl']) || $page_has_image_entity; if (!$has_image) { $miss[] = 'image'; } if (!empty($miss)) { $article_missing_fields[] = ['url' => $url, 'type' => $type, 'missing' => $miss]; } } // FAQPage — questions must have acceptedAnswer if ($type === 'faqpage') { $main = isset($item['mainEntity']) && is_array($item['mainEntity']) ? $item['mainEntity'] : []; $no_answer = 0; foreach ($main as $q) { if (is_array($q) && $this->normalize_type($q['@type'] ?? '') === 'question' && empty($q['acceptedAnswer'])) { $no_answer++; } } if ($no_answer > 0) { $faq_no_answers[] = ['url' => $url, 'questions_without_answer' => $no_answer]; } } // VideoObject completeness // v1.0.59: relaxed thresholds: // - thumbnailUrl can come from `thumbnail`, `image`, or `contentUrl` // (Rank Math Video module emits `thumbnail` instead). // - uploadDate is now optional for embedded YouTube/Vimeo // videos (detected by `embedUrl` containing youtube/vimeo // domain) — Google reads the date from the platform // metadata when the video is embedded. if ($type === 'videoobject') { $miss = []; if (empty($item['name'])) { $miss[] = 'name'; } if (empty($item['description'])) { $miss[] = 'description'; } $has_thumb = !empty($item['thumbnailUrl']) || !empty($item['thumbnail']) || !empty($item['image']) || (!empty($item['contentUrl']) && !empty($item['name'])); if (!$has_thumb) { $miss[] = 'thumbnailUrl (or thumbnail/image)'; } $is_embedded = false; if (!empty($item['embedUrl'])) { $embed = is_string($item['embedUrl']) ? $item['embedUrl'] : ''; if (stripos($embed, 'youtube') !== false || stripos($embed, 'vimeo') !== false) { $is_embedded = true; } } if (empty($item['uploadDate']) && !$is_embedded) { $miss[] = 'uploadDate'; } if (!empty($miss)) { $video_incomplete[] = ['url' => $url, 'missing' => $miss]; } } } } // Pages with no schema at all (content pages only) if (empty($types) && (int) ($p['word_count'] ?? 0) >= 300) { $no_schema_at_all[] = ['url' => $url]; } } if (!empty($missing_required_props)) { $findings[] = $this->finding( 'schema_missing_required', I18n::tr('The homepage has no business details for search engines', 'لا توجد بيانات تعريف بالمنشأة في الصفحة الرئيسية'), 'AI-Friendly', 'med', -6, [ 'source' => 'html', 'details' => I18n::tr('Missing Organization/WebSite schema on homepage', 'لا توجد بيانات تعريف بالموقع أو المنشأة (Organization أو WebSite) في الصفحة الرئيسية'), 'samples' => $missing_required_props, ], I18n::tr('Organization/WebSite schema on the homepage is important so the site is identified in the Knowledge Graph.', 'بيانات المنشأة أو الموقع في الصفحة الرئيسية تعرّف جوجل بموقعك ككيان واضح في لوحة المعرفة.'), [ I18n::tr('Add Organization schema with at least name, url, and logo.', 'أضف بيانات المنشأة (Organization) وفيها الاسم والرابط والشعار على الأقل.'), I18n::tr('Use WebSite schema with searchAction where possible.', 'أضف بيانات الموقع (WebSite) مع مربع البحث (searchAction) إن أمكن.'), ], [ 'how' => I18n::tr('Test the homepage in Google Rich Results Test, then run a new audit.', 'اختبر الصفحة الرئيسية في أداة Rich Results Test من جوجل، ثم شغّل فحصًا جديدًا.'), 'pass_condition' => I18n::tr('The homepage has Organization or WebSite structured data.', 'تحتوي الصفحة الرئيسية على بيانات منظمة من نوع Organization أو WebSite.'), ], $this->id() ); } if (!empty($product_pages_no_schema)) { $findings[] = $this->finding( 'schema_product_missing', I18n::tr('Product pages without Product schema', 'صفحات منتجات بلا بيانات منتج (Product)'), 'AI-Friendly', 'high', -7, [ 'source' => 'html', 'details' => I18n::tr('Single product pages (permalink containing /product/, /products/, or /p|item/) without Product schema. Listing/category pages (CollectionPage/ItemList) are excluded because they do not need Product.', 'صفحات منتجات مفردة بلا بيانات منتج. صفحات القوائم والتصنيفات مستثناة لأنها لا تحتاج هذه البيانات.'), 'affected_count' => count($product_pages_no_schema), 'shown_in_samples' => min(10, count($product_pages_no_schema)), 'sample_limit' => 10, 'samples' => array_slice($product_pages_no_schema, 0, 10), ], I18n::tr('Product schema makes products eligible for Google product results that can show price, rating and availability.', 'بيانات المنتج تجعل منتجاتك مؤهلة لنتائج جوجل التي قد تعرض السعر والتقييم والتوفر.'), [ I18n::tr('Use WooCommerce with your SEO plugin to add product data automatically.', 'استخدم ووكومرس مع إضافة تحسين محركات البحث لإضافة بيانات المنتج تلقائيًا.'), I18n::tr('Make sure every product includes: name, image, offers, brand, sku.', 'تأكد أن كل منتج يحتوي الاسم والصورة والعرض (السعر) والعلامة التجارية ورمز المنتج.'), I18n::tr('Reference: https://schema.org/Product', 'المرجع: https://schema.org/Product'), ], [ 'how' => I18n::tr('Run the audit after adding Product schema.', 'شغّل فحصًا جديدًا بعد إضافة بيانات المنتج.'), 'pass_condition' => I18n::tr('Every product page has product data.', 'كل صفحات المنتجات تحتوي بيانات المنتج.'), ], $this->id() ); } if (!empty($product_incomplete)) { $findings[] = $this->finding( 'schema_product_incomplete', I18n::tr('Product schema incomplete (important fields missing)', 'بيانات المنتج ناقصة (حقول مهمة غير موجودة)'), 'AI-Friendly', 'med', -5, [ 'source' => 'html', 'details' => I18n::tr('Product schema missing required fields (name/image/offers)', 'بيانات المنتج تنقصها حقول مطلوبة (الاسم أو الصورة أو العرض)') . ($any_jsonld_truncated ? I18n::tr(' — Note P2.3: some pages have large JSON-LD that was truncated for storage, so the field may live in a deeper entity. Review these pages manually before treating them as incomplete.', ': ملاحظة: بعض الصفحات تحتوي JSON-LD كبيرًا اقتُطع عند التخزين وقد يكون الحقل موجودًا في كيان أبعد. راجع هذه الصفحات يدويًا قبل اعتبارها ناقصة.') : ''), 'schema_data_may_be_truncated' => $any_jsonld_truncated, 'samples' => array_slice($product_incomplete, 0, 10), ], I18n::tr('Missing fields in Product schema prevent Rich Results from appearing in Google.', 'الحقول الناقصة في بيانات المنتج تمنع ظهور السعر والتقييم في نتائج جوجل.'), [ I18n::tr('Make sure every Product includes: name, image (URL), offers (price + priceCurrency + availability).', 'تأكد أن كل منتج يحتوي الاسم ورابط الصورة والعرض بالسعر والعملة والتوفر.'), I18n::tr('Add brand, sku, and aggregateRating where possible.', 'أضف العلامة التجارية ورمز المنتج ومتوسط التقييم إن أمكن.'), ], [ 'how' => I18n::tr('Run the audit after completing the fields.', 'شغّل فحصًا جديدًا بعد إكمال الحقول.'), 'pass_condition' => I18n::tr('All product data has a name, image and offer.', 'كل بيانات المنتجات تحتوي الاسم والصورة والعرض.'), ], $this->id() ); } if (!empty($localbusiness_incomplete)) { $findings[] = $this->finding( 'schema_localbusiness_incomplete', I18n::tr('LocalBusiness schema incomplete', 'بيانات النشاط المحلي (LocalBusiness) ناقصة'), 'AI-Friendly', 'med', -5, [ 'source' => 'html', 'details' => I18n::tr('LocalBusiness schema missing address/telephone/openingHours/geo', 'بيانات النشاط المحلي تنقصها العنوان أو الهاتف أو ساعات العمل أو الموقع الجغرافي') . ($any_jsonld_truncated ? I18n::tr(' — Note P2.3: large JSON-LD was truncated on some pages, so some fields may live in a deeper entity. Review manually.', ' ملاحظة: بيانات بعض الصفحات كبيرة واقتُطع جزء منها، فقد تكون بعض الحقول موجودة في جزء لم يُقرأ. راجع يدويًا.') : ''), 'schema_data_may_be_truncated' => $any_jsonld_truncated, 'samples' => array_slice($localbusiness_incomplete, 0, 10), ], I18n::tr('An incomplete LocalBusiness schema weakens the business\'s visibility in Google Maps and the Local Pack.', 'بيانات النشاط المحلي الناقصة تضعف ظهور نشاطك في خرائط جوجل ونتائج البحث المحلية.'), [ I18n::tr('Add the full fields: address (PostalAddress), telephone, openingHoursSpecification, geo (GeoCoordinates).', 'أضف العنوان الكامل والهاتف وساعات العمل والإحداثيات الجغرافية.'), I18n::tr('Make sure the name/address/phone (NAP) match your Google Business Profile.', 'تأكد أن الاسم والعنوان والهاتف مطابقة لملفك التجاري في جوجل.'), ], [ 'how' => I18n::tr('Run the audit after completing the fields.', 'شغّل فحصًا جديدًا بعد إكمال الحقول.'), 'pass_condition' => I18n::tr('LocalBusiness data has address, phone, opening hours and location.', 'بيانات النشاط المحلي تحتوي العنوان والهاتف وساعات العمل والموقع.'), ], $this->id() ); } if (!empty($review_no_rating)) { $findings[] = $this->finding( 'schema_review_missing_rating', I18n::tr('Review/AggregateRating schema without valid rating values', 'بيانات التقييمات بلا قيم تقييم صحيحة'), 'AI-Friendly', 'low', -3, [ 'source' => 'html', 'details' => I18n::tr('Review/AggregateRating schema present but missing rating values', 'توجد بيانات تقييمات لكن قيم التقييم ناقصة'), 'samples' => array_slice($review_no_rating, 0, 10), ], I18n::tr('Review schema without reviewRating, or AggregateRating without ratingValue/reviewCount, is not shown as a Rich Snippet.', 'التقييم الذي لا يحتوي قيمة التقييم وعدد المراجعات لا يظهر كنجوم في نتائج جوجل.'), [ I18n::tr('In Review: add reviewRating as a Rating with ratingValue (1-5).', 'في كل مراجعة أضف قيمة التقييم (من 1 إلى 5).'), I18n::tr('In AggregateRating: add ratingValue + reviewCount/ratingCount.', 'في متوسط التقييم أضف القيمة وعدد المراجعات.'), ], [ 'how' => I18n::tr('Run the audit after completing the fields.', 'شغّل فحصًا جديدًا بعد إكمال الحقول.'), 'pass_condition' => I18n::tr('Review/AggregateRating includes valid rating values.', 'Review/AggregateRating يحتوي قيم تقييم صحيحة.'), ], $this->id() ); } if (!empty($breadcrumb_invalid)) { $findings[] = $this->finding( 'schema_breadcrumb_invalid', I18n::tr('BreadcrumbList schema invalid', 'بيانات مسار التنقل غير صحيحة'), 'AI-Friendly', 'low', -2, [ 'source' => 'html', 'details' => I18n::tr('BreadcrumbList schema with fewer than 2 itemListElement entries', 'بيانات مسار التنقل تحتوي أقل من عنصرين'), 'samples' => array_slice($breadcrumb_invalid, 0, 8), ], I18n::tr('BreadcrumbList needs at least two items to be shown in the SERP.', 'يحتاج مسار التنقل عنصرين على الأقل ليظهر في نتائج البحث.'), [ I18n::tr('Make sure every breadcrumb has at least: Home > Section > Page.', 'تأكد أن كل مسار تنقل يحتوي على الأقل: الرئيسية ثم القسم ثم الصفحة.'), I18n::tr('Each item includes position, name, and item (URL).', 'كل عنصر يحتوي الترتيب والاسم والرابط.'), ], [ 'how' => I18n::tr('Run the audit after fixing the breadcrumb.', 'شغّل فحصًا جديدًا بعد إصلاح مسار التنقل.'), 'pass_condition' => I18n::tr('Breadcrumb data has 2 or more valid items.', 'بيانات مسار التنقل تحتوي عنصرين صحيحين أو أكثر.'), ], $this->id() ); } // Article / BlogPosting schema incomplete if (!empty($article_missing_fields)) { $findings[] = $this->finding( 'schema_article_incomplete', I18n::tr('Article/BlogPosting schema incomplete (headline/author/datePublished/image)', 'بيانات المقال ناقصة (العنوان أو الكاتب أو تاريخ النشر أو الصورة)'), 'AI-Friendly', 'med', -6, [ 'source' => 'html', 'details' => count($article_missing_fields) . ' Article/BlogPosting/NewsArticle blocks missing required fields', 'samples' => array_slice($article_missing_fields, 0, 8), ], I18n::tr('Complete Article schema is essential for Google Rich Results and AI Overviews. Missing fields stop Google from showing the article as a Rich Result and weaken E-E-A-T.', 'بيانات المقال الكاملة ضرورية لظهوره بشكل مميز في جوجل وفي إجابات الذكاء الاصطناعي، والحقول الناقصة تمنع ذلك وتضعف تقييم الموثوقية.'), [ I18n::tr('Add a headline that matches the page\'s H1.', 'أضف عنوانًا يطابق العنوان الرئيسي (H1) للصفحة.'), I18n::tr('Add author as a Person entity (name is required at minimum).', 'أضف الكاتب كشخص (Person) مع اسمه على الأقل.'), I18n::tr('Add datePublished and dateModified in ISO 8601 format.', 'أضف تاريخ النشر وتاريخ التحديث بصيغة ISO 8601.'), I18n::tr('Add an image of at least 1200×630.', 'أضف صورة بأبعاد 1200×630 على الأقل.'), I18n::tr('Your SEO plugin can fill these fields automatically.', 'يمكن لإضافة تحسين محركات البحث ملء هذه الحقول تلقائيًا.'), ], [ 'how' => I18n::tr('Test with the Google Rich Results Test.', 'اختبر الصفحة في أداة Rich Results Test من جوجل.'), 'pass_condition' => I18n::tr('Article data has headline, author, publish date and image.', 'بيانات المقال تحتوي العنوان والكاتب وتاريخ النشر والصورة.'), ], $this->id() ); } // FAQPage incomplete answers if (!empty($faq_no_answers)) { $findings[] = $this->finding( 'schema_faqpage_missing_answers', I18n::tr('FAQPage schema: questions without acceptedAnswer', 'بيانات الأسئلة الشائعة فيها أسئلة بلا إجابات'), 'AI-Friendly', 'med', -5, [ 'source' => 'html', 'details' => count($faq_no_answers) . ' FAQPage blocks have questions without acceptedAnswer', 'samples' => array_slice($faq_no_answers, 0, 6), ], I18n::tr('A FAQPage question without an acceptedAnswer is incomplete markup: the question has no answer that search engines can read.', 'السؤال بلا إجابة في بيانات الأسئلة الشائعة بيانات ناقصة، فلا توجد إجابة تقرؤها محركات البحث.'), [ I18n::tr('Every question in mainEntity must include acceptedAnswer as an Answer entity.', 'يجب أن يحتوي كل سؤال على إجابته (acceptedAnswer).'), I18n::tr('Make sure the answer text in acceptedAnswer.text is also visibly present on the page.', 'تأكد أن نص الإجابة في acceptedAnswer.text موجود أيضاً بشكل مرئي في الصفحة.'), I18n::tr('5-8 questions is ideal — do not overdo it.', '5-8 أسئلة هي المثالية — لا تُكثر.'), I18n::tr('The most-searched questions take priority.', 'الأسئلة الأكثر بحثاً لها أولوية.'), ], [ 'how' => I18n::tr('Test with the Google Rich Results Test.', 'اختبر الصفحة في أداة Rich Results Test من جوجل.'), 'pass_condition' => I18n::tr('Every FAQ question has an answer.', 'كل أسئلة بيانات الأسئلة الشائعة لها إجابات.'), ], $this->id() ); } // VideoObject incomplete if (!empty($video_incomplete)) { $findings[] = $this->finding( 'schema_videoobject_incomplete', I18n::tr('VideoObject schema incomplete (name/thumbnailUrl/uploadDate)', 'بيانات الفيديو ناقصة (الاسم أو الصورة المصغرة أو تاريخ الرفع)'), 'AI-Friendly', 'low', -3, [ 'source' => 'html', 'details' => count($video_incomplete) . ' VideoObject blocks missing required fields', 'samples' => array_slice($video_incomplete, 0, 6), ], I18n::tr('Complete VideoObject schema lets Google show the video in Video Carousels and helps AI systems understand the video content without playing it.', 'بيانات الفيديو الكاملة تتيح لجوجل عرض الفيديو في نتائج الفيديو، وتساعد الذكاء الاصطناعي على فهم محتواه دون تشغيله.'), [ I18n::tr('Add name (the video title).', 'أضف اسم الفيديو.'), I18n::tr('Add a high-quality thumbnailUrl (at least 1280×720).', 'أضف صورة مصغرة عالية الجودة (1280×720 على الأقل).'), I18n::tr('Add uploadDate in ISO 8601 format.', 'أضف تاريخ الرفع بصيغة ISO 8601.'), I18n::tr('Add duration in ISO 8601 format (example: PT5M30S = 5 minutes 30 seconds).', 'أضف مدة الفيديو بصيغة ISO 8601 (مثال: PT5M30S تعني 5 دقائق و30 ثانية).'), I18n::tr('Add a descriptive description.', 'أضف وصفًا للفيديو.'), ], [ 'how' => I18n::tr('Test with the Google Rich Results Test.', 'اختبر الصفحة في أداة Rich Results Test من جوجل.'), 'pass_condition' => I18n::tr('Video data has name, thumbnail and upload date.', 'بيانات الفيديو تحتوي الاسم والصورة المصغرة وتاريخ الرفع.'), ], $this->id() ); } // v3.3.48 — schema types unsupported by Google rich results. // v3.3.49 — round-4 review pointed out that calling these // "deprecated" overclaims: Schema.org still defines the types and // they aren't "deprecated" per the W3C spec. What Google has done // is drop rich-result support. Rename + soften wording accordingly. $unsupported_types_map = [ 'sitenavigationelement' => 'SiteNavigationElement (not in Google rich-result gallery; retired ~2014)', 'criticreview' => 'CriticReview (no rich result; use Review instead)', 'howtotool' => 'HowToTool (HowTo rich results were deprecated 2023)', ]; $unsupported_hits = []; $unsupported_total = 0; // v3.3.52 — true count; samples capped at 10 below. foreach ($content_pages as $p2) { if (!is_array($p2)) { continue; } $types2 = isset($p2['jsonld_types']) && is_array($p2['jsonld_types']) ? $p2['jsonld_types'] : []; $found_on_page = []; foreach ($types2 as $t2) { $key = strtolower((string) $t2); if (isset($unsupported_types_map[$key])) { $found_on_page[] = $unsupported_types_map[$key]; } } if (!empty($found_on_page)) { $unsupported_total++; if (count($unsupported_hits) < 10) { $unsupported_hits[] = [ 'url' => (string) ($p2['url'] ?? ''), 'types' => array_values(array_unique($found_on_page)), ]; } } } if (!empty($unsupported_hits)) { $findings[] = $this->finding( 'schema_unsupported_for_rich_results', sprintf(I18n::tr('%d pages use Schema types not supported in Google rich results', 'صفحات تستخدم أنواع بيانات منظمة لا تدعمها نتائج جوجل (%d)'), $unsupported_total), 'AI-Friendly', 'info', 0, [ 'source' => 'jsonld', 'details' => I18n::tr('These types have no Rich Result template in the Google search gallery (developers.google.com/search/docs/appearance/structured-data/search-gallery). Schema.org still defines them, but their presence will not give the site an enhanced SERP. Removing them means simpler JSON-LD, not a compliance \'fix\'.', 'لا تملك هذه الأنواع قالب نتيجة غنية في معرض Google (developers.google.com/search/docs/appearance/structured-data/search-gallery). وما زال Schema.org يعرّفها لكن وجودها لن يمنح الموقع مظهرًا محسّنًا في نتائج البحث. وحذفها يبسّط الـ JSON-LD ولا يُعد إصلاحًا إلزاميًا.'), 'affected_count' => $unsupported_total, 'shown_in_samples' => count($unsupported_hits), 'samples' => $unsupported_hits, 'note' => 'Soft recommendation — no scoring penalty. Removal is hygiene, not a defect.', ], I18n::tr('Schema.org keeps the legacy types, but the Google search gallery (the official reference for rich results) does not include SiteNavigationElement / CriticReview / HowToTool. Their presence does no harm but adds no SERP feature. Most themes/plugins add them by default — removing them simplifies the JSON-LD that AI consumers read.', 'بعض الأنواع القديمة مثل SiteNavigationElement وCriticReview وHowToTool لا تدعمها نتائج جوجل. وجودها لا يضر لكنه لا يضيف أي ميزة، وتضيفها قوالب وإضافات كثيرة تلقائيًا. حذفها يبسّط البيانات التي يقرأها الذكاء الاصطناعي.'), [ I18n::tr('Open the theme or plugin that adds the schema (usually the theme\'s functions.php or an old SEO plugin).', 'افتح القالب أو الإضافة التي تضيف هذه البيانات، وغالبًا تكون في ملف functions.php أو في إضافة قديمة لتحسين محركات البحث.'), I18n::tr('Remove the SiteNavigationElement / CriticReview / HowToTool blocks.', 'احذف كتل SiteNavigationElement وCriticReview وHowToTool.'), I18n::tr('Replace them with BreadcrumbList for navigation (the modern standard).', 'استخدم بيانات مسار التنقل (BreadcrumbList) بدلًا منها للتنقل.'), I18n::tr('Verify: https://search.google.com/test/rich-results — if these still show on the page, the markup adds nothing.', 'للتحقق استخدم https://search.google.com/test/rich-results، فإذا تجاهلت الأداة هذه الأنواع فلا فائدة من إبقائها.'), ], [ 'how' => I18n::tr('Check the JSON-LD with the Schema.org Validator + Google Rich Results Test.', 'افحص البيانات في Schema.org Validator وفي أداة Rich Results Test من جوجل.'), 'pass_condition' => I18n::tr('No types that Google does not support.', 'لا توجد أنواع لا تدعمها جوجل.'), ], $this->id() ); } // Content pages with no schema at all if (count($no_schema_at_all) >= 5) { $findings[] = $this->finding( 'schema_no_structured_data', I18n::tr('Content pages without any Structured Data', 'صفحات محتوى بلا أي بيانات منظمة'), 'AI-Friendly', count($no_schema_at_all) >= 15 ? 'med' : 'low', count($no_schema_at_all) >= 15 ? -4 : -2, [ 'source' => 'html', 'details' => count($no_schema_at_all) . ' content pages (300+ words) have zero JSON-LD structured data', 'samples' => array_slice($no_schema_at_all, 0, 8), ], I18n::tr('Structured data helps Google and AI systems understand the content type and context. Its absence deprives pages of Rich Results and weakens the entity signal.', 'البيانات المنظمة تساعد جوجل والذكاء الاصطناعي على فهم نوع المحتوى وسياقه، وغيابها يحرم الصفحات من الظهور المميز في النتائج.'), [ I18n::tr('Add Article or BlogPosting schema to article pages.', 'أضف بيانات المقال (Article أو BlogPosting) إلى صفحات المقالات.'), I18n::tr('Add WebPage schema as a minimum for any page.', 'أضف بيانات الصفحة (WebPage) كحد أدنى لأي صفحة.'), I18n::tr('Your SEO plugin can add structured data automatically.', 'يمكن لإضافة تحسين محركات البحث إضافة البيانات المنظمة تلقائيًا.'), ], [ 'how' => I18n::tr('Run the audit after adding Structured Data.', 'شغّل فحصًا جديدًا بعد إضافة البيانات المنظمة.'), 'pass_condition' => I18n::tr('All content pages include JSON-LD schema.', 'كل صفحات المحتوى تحتوي JSON-LD schema.'), ], $this->id() ); } return $findings; } /** * v3.3.52 P1.2 — true only for SINGLE product pages, not listing/category * archives. The old heuristic matched bare `/shop/` and `/products/` * (catalog roots) and produced a high-severity "Product schema missing" * false positive on every WooCommerce/Shopify category page. * * A single product permalink requires a SLUG segment after the product * keyword (WooCommerce `/product//`, Shopify `/products/`, * short `/p|item|dp/`), and must not sit under a known listing, * category, tag, collection, or search base. */ private function url_looks_like_single_product(string $url): bool { $path = strtolower((string) wp_parse_url($url, PHP_URL_PATH)); if ($path === '') { return false; } // Listing / archive / taxonomy bases that often carry product // keywords but are NOT a single product page. $listing_markers = [ '/shop', '/product-category/', '/product-tag/', '/collections/', '/category/', '/categories/', '/product-categories/', '/تصنيف', '/الفئات', '/المتجر', '/المنتجات', '/قسم', ]; foreach ($listing_markers as $m) { if (strpos($path, $m) !== false) { return false; } } // Single product permalink: keyword + a real slug segment. return (bool) preg_match('~/(?:product|products|p|item|dp)/[^/]+~', $path); } /** * Flatten a JSON-LD block (handle @graph, nested arrays). * * @param array $block * @return array> */ private function flatten_jsonld(array $block): array { $items = []; if (isset($block['@graph']) && is_array($block['@graph'])) { foreach ($block['@graph'] as $g) { if (is_array($g)) { $items[] = $g; } } } else { $items[] = $block; } return $items; } /** * @param mixed $type */ private function normalize_type($type): string { if (is_array($type)) { $type = isset($type[0]) ? $type[0] : ''; } return strtolower((string) $type); } }