30% of its word characters are Arabic codepoints. */ private static function is_arabic( $text ) { preg_match_all( '/\p{L}/u', $text, $all ); $total = count( $all[0] ); if ( $total === 0 ) return false; preg_match_all( self::ARABIC_REGEX, $text, $ar ); return ( count( $ar[0] ) / $total ) > 0.30; } /** * Unify Arabic letter forms that are orthographically distinct but * treated as interchangeable by real searchers and writers: hamza-seated * alef forms all collapse to bare alef, alef maqsura collapses to yaa, * and taa marbuta collapses to haa. Diacritics and the tatweel * elongation character are stripped entirely. Non-Arabic characters * pass through untouched, so this is safe to run on any string. * * Without this, a focus keyword spelled "مدرسة" would not match content * spelled "مدرسه" (or vice versa) even though any Arabic reader treats * them as the same word - this was the plugin's biggest Arabic * keyword-matching gap. */ private static function normalize_arabic( $text ) { if ( '' === $text ) return $text; $text = preg_replace( self::ARABIC_DIACRITICS_REGEX, '', $text ); $text = str_replace( [ 'أ', 'إ', 'آ', 'ٱ' ], 'ا', $text ); $text = str_replace( 'ى', 'ي', $text ); $text = str_replace( 'ة', 'ه', $text ); return $text; } /** * The single function every keyword-vs-content comparison in analyze() * should go through: lowercases (a no-op for Arabic, correct for * Latin/mixed strings) and normalizes Arabic letter forms. Safe to call * on any string regardless of language. */ private static function normalize_for_compare( $text ) { return self::normalize_arabic( mb_strtolower( (string) $text ) ); } /** * Light Arabic stemmer (Larkey-style): strips one leading proclitic * (و/ف/ب/ك/ل, the definite article ال, and its fused forms like وال/بال) * and one trailing suffix (pronoun, plural, or feminine ending) so * inflected forms of the same word - "المدرستين", "مدرستها", "مدارسنا" - * reduce to a comparable stem. Deliberately shallow: it trades some * linguistic precision for match recall, and callers only use it as a * fallback once an exact (normalized) match has already failed, never * to override a real match. Words under 4 characters are returned * unchanged - too short to safely strip anything from. */ private static function light_stem_ar( $word ) { $word = trim( (string) $word ); if ( mb_strlen( $word ) < 4 ) return $word; $prefixes = [ 'وال', 'فال', 'بال', 'كال', 'ولل', 'فلل', 'ال', 'و', 'ف', 'ب', 'ك', 'ل' ]; foreach ( $prefixes as $p ) { $plen = mb_strlen( $p ); if ( mb_substr( $word, 0, $plen ) === $p && ( mb_strlen( $word ) - $plen ) >= 3 ) { $word = mb_substr( $word, $plen ); break; } } $suffixes = [ 'هما', 'كما', 'تين', 'يه', 'ات', 'ون', 'ين', 'ان', 'ها', 'هم', 'هن', 'كم', 'كن', 'نا' ]; foreach ( $suffixes as $s ) { $slen = mb_strlen( $s ); if ( mb_substr( $word, -$slen ) === $s && ( mb_strlen( $word ) - $slen ) >= 3 ) { $word = mb_substr( $word, 0, mb_strlen( $word ) - $slen ); break; } } return $word; } /** * light_stem_ar() alone under-matches one very common pattern: a * feminine noun's taa marbuta normalizes to haa when the word appears * bare ("مدرسة" -> "مدرسه"), but standard Arabic orthography swaps it * for a plain taa when a pronoun suffix is attached before that suffix * is stripped ("مدرسة" + "ها" is written "مدرستها", never "مدرسةها"). * A single stem comparison misses that pair entirely. This returns the * stem plus, where relevant, one extra candidate covering that * alternation, so callers can match if *any* candidate coincides. */ private static function ar_stem_candidates( $word ) { $stem = self::light_stem_ar( $word ); $candidates = [ $stem ]; if ( mb_substr( $stem, -1 ) === 'ه' && mb_strlen( $stem ) >= 3 ) { $candidates[] = mb_substr( $stem, 0, mb_strlen( $stem ) - 1 ); } if ( mb_substr( $stem, -1 ) === 'ت' && mb_strlen( $stem ) >= 3 ) { $candidates[] = mb_substr( $stem, 0, mb_strlen( $stem ) - 1 ) . 'ه'; } return $candidates; } /** * Count words in a string - works for both English and Arabic. * * PHP's str_word_count() uses locale-aware byte sequences and returns 0 * for Arabic text. Instead we split on whitespace/punctuation and count * non-empty tokens, which is universally correct. */ /** * Unicode-aware word count (Arabic, Latin, etc.) - deliberately public * so other classes (e.g. the Pro AI Answer Simulator) don't reach for * PHP's str_word_count(), which only recognizes ASCII letters and * silently returns 0 (or near it) for Arabic content. */ public static function count_words( $text ) { // Match sequences of Unicode letters/digits (handles Arabic, Latin, etc.) preg_match_all( '/[\p{L}\p{N}]+/u', $text, $m ); return count( $m[0] ); } /** * Count occurrences of $keyword in $content for Arabic morphology. * * For Arabic, exact-phrase matching misses inflected/root forms, so we * also count word-by-word: a match is counted if every word of the keyword * appears within a 10-word window in the content. For English or short * single-word keywords the simple substr_count() path is used. */ private static function count_keyword_occurrences( $content_lower, $kw_lower, $is_arabic ) { $exact = substr_count( $content_lower, $kw_lower ); if ( ! $is_arabic || $exact > 0 ) { return $exact; } // Arabic morphological approximation: tokenise and look for all // keyword words appearing in a sliding window. $kw_words = preg_split( '/\s+/u', trim( $kw_lower ), -1, PREG_SPLIT_NO_EMPTY ); $content_words = preg_split( '/\s+/u', trim( $content_lower ), -1, PREG_SPLIT_NO_EMPTY ); if ( count( $kw_words ) < 2 ) { // Single-word keyword: count content words whose stem // candidates overlap the keyword's stem candidates. $root_candidates = self::ar_stem_candidates( $kw_words[0] ); $count = 0; foreach ( $content_words as $w ) { if ( array_intersect( self::ar_stem_candidates( $w ), $root_candidates ) ) $count++; } return $count; } // Multi-word keyword: sliding window $window = count( $kw_words ) + 4; $count = 0; $n = count( $content_words ); for ( $i = 0; $i <= $n - count( $kw_words ); $i++ ) { $slice = array_slice( $content_words, $i, min( $window, $n - $i ) ); $all_found = true; foreach ( $kw_words as $kw ) { $root_candidates = self::ar_stem_candidates( $kw ); $found = false; foreach ( $slice as $w ) { if ( array_intersect( self::ar_stem_candidates( $w ), $root_candidates ) ) { $found = true; break; } } if ( ! $found ) { $all_found = false; break; } } if ( $all_found ) { $count++; $i += count( $kw_words ) - 1; } } return $count; } /** * True if $haystack_lower contains the primary focus keyword or, when * set, its Arabizi/transliterated variant. Lets code-switched content - * an Arabic dialect written in Latin letters, or an article that mixes * Arabic script with an English/Latin rendering of the same term - pass * the same keyword checks a single-script page would, instead of only * ever checking the Arabic-script form. */ private static function matches_kw( $haystack_lower, $kw_lower, $arabizi_lower ) { if ( false !== mb_strpos( $haystack_lower, $kw_lower ) ) { return true; } return '' !== $arabizi_lower && false !== mb_strpos( $haystack_lower, $arabizi_lower ); } /** * @param int $post_id * @param array $overrides Optional live values from the React meta box: * [ 'focus_keyword' => '', 'title' => '', 'description' => '' ] * Any key present here takes precedence over the DB value. */ public static function analyze( $post_id, array $overrides = [] ) { $post = get_post( $post_id ); $keyword = isset( $overrides['focus_keyword'] ) ? $overrides['focus_keyword'] : AWBSEO_Meta_Manager::get( $post_id, 'focus_keyword' ); // Arabizi/transliterated variant of the focus keyword, captured via the // bolt-on field next to it in the meta box - optional, only ever used // as an alternative match alongside $keyword, never in place of it. $arabizi = isset( $overrides['focus_keyword_arabizi'] ) ? $overrides['focus_keyword_arabizi'] : AWBSEO_Meta_Manager::get( $post_id, 'focus_keyword_arabizi' ); $title = ( isset( $overrides['title'] ) && $overrides['title'] !== '' ) ? $overrides['title'] : AWBSEO_Meta_Manager::get_title( $post_id ); $desc = ( isset( $overrides['description'] ) && $overrides['description'] !== '' ) ? $overrides['description'] : AWBSEO_Meta_Manager::get_description( $post_id ); // Rendered HTML (shortcodes/blocks processed) - the structural checks // below (images, alt text, links, headings) scan this instead of raw // post_content, so page builders that store shortcodes rather than // literal //

tags (WPBakery, Elementor in shortcode mode, // etc.) are still detected correctly. get_rendered_content() also // strips any shortcode tag the builder plugin couldn't expand (e.g. // if it's deactivated), so leftover bracket syntax/base64 payloads // never get scanned as if they were real content. $rendered = AWBSEO_Core::get_rendered_content( $post ); $content = wp_strip_all_tags( $rendered ); // Fallback 1: extract text directly from raw post_content shortcodes // (WPBakery stores content as shortcodes; callbacks don't fire in admin). if ( $post && ! empty( $post->post_content ) && self::count_words( $content ) < 100 ) { $raw_text = AWBSEO_Core::extract_raw_text( $post->post_content ); if ( self::count_words( $raw_text ) > self::count_words( $content ) ) { $content = $raw_text; } } // Fallback 2: HTTP fetch of the public URL. // When page builder callbacks produce empty HTML in admin AJAX context // (WPBakery, Elementor, Divi, etc.) and the raw shortcode extraction // also fails, fetching the rendered frontend page is the only reliable // way to get the actual word count. This is a manual trigger (user // clicks Analyze), so the extra round-trip is acceptable. if ( $post && self::count_words( $content ) < 100 ) { $permalink = get_permalink( $post->ID ); if ( $permalink ) { $http = wp_remote_get( $permalink, [ 'timeout' => 5, 'sslverify' => false, ] ); if ( ! is_wp_error( $http ) && 200 === (int) wp_remote_retrieve_response_code( $http ) ) { $fetched = wp_strip_all_tags( wp_remote_retrieve_body( $http ) ); if ( self::count_words( $fetched ) > self::count_words( $content ) ) { $content = $fetched; } } } } $content = apply_filters( 'awbseo_analyzed_content', $content, $post_id ); $url = get_permalink( $post_id ); $slug = $post->post_name; $checks = []; // 1. Focus keyword set. $checks[] = $keyword ? self::pass( 'focus_keyword', 'Focus keyword is set.' ) : self::fail( 'focus_keyword', 'No focus keyword set. Add a focus keyword so AWBSEO can check your content.' ); // Detect content language once - reused by density and word-count checks. $is_arabic = self::is_arabic( $content ); if ( $keyword ) { $kw_lower = self::normalize_for_compare( $keyword ); $content_lower = self::normalize_for_compare( $content ); $title_lower = self::normalize_for_compare( $title ); $desc_lower = self::normalize_for_compare( $desc ); $slug_lower = mb_strtolower( $slug ); // slugs are handled separately via sanitize_title() below $arabizi_lower = $arabizi ? self::normalize_for_compare( $arabizi ) : ''; // 2. Keyword in title. $checks[] = self::matches_kw( $title_lower, $kw_lower, $arabizi_lower ) ? self::pass( 'kw_in_title', 'Focus keyword found in SEO title.' ) : self::fail( 'kw_in_title', "Your focus keyword '{$keyword}' is not in the SEO title. Add it - ideally near the beginning." ); // 3. Keyword in meta description. $checks[] = self::matches_kw( $desc_lower, $kw_lower, $arabizi_lower ) ? self::pass( 'kw_in_desc', 'Focus keyword found in meta description.' ) : self::warn( 'kw_in_desc', "Your focus keyword '{$keyword}' is missing from the meta description. Include it naturally." ); // 4. Keyword in URL slug. // Slugs are hyphenated ("growth-foundation"), but keywords are // plain text with spaces ("growth foundation") - a literal // substring match against $slug_lower never matches, so we // normalize the keyword the same way WordPress builds the slug // before comparing. $kw_slug = sanitize_title( $keyword ); $kw_slug_arabizi = $arabizi ? sanitize_title( $arabizi ) : ''; $slug_check = ( '' !== $kw_slug && false !== strpos( $slug_lower, $kw_slug ) ) || ( '' !== $kw_slug_arabizi && false !== strpos( $slug_lower, $kw_slug_arabizi ) ); // Prefer the Arabizi form in the suggestion when one is set - it's // plain ASCII and won't come back as a percent-encoded slug the // way a raw Arabic keyword does. $slug_suggestion = $kw_slug_arabizi ?: urldecode( $kw_slug ); $checks[] = $slug_check ? self::pass( 'kw_in_slug', 'Focus keyword found in the URL slug.' ) : self::warn( 'kw_in_slug', "The URL slug doesn't contain your focus keyword. Consider using a slug like '/" . $slug_suggestion . "'." ); // $kw_slug itself (used in $slug_check above) is untouched, so the original pass/fail comparison is unchanged when no Arabizi variant is set. // 5. Keyword density (0.5% – 3%). // Use Unicode-safe word counter so Arabic text is counted correctly. $word_count = self::count_words( $content ); $kw_count = $word_count > 0 ? self::count_keyword_occurrences( $content_lower, $kw_lower, $is_arabic ) : 0; // Code-switched content often repeats the keyword in its Arabizi // form rather than (or alongside) Arabic script - count both so // density reflects total keyword usage, not only Arabic-script hits. if ( $word_count > 0 && '' !== $arabizi_lower ) { $kw_count += self::count_keyword_occurrences( $content_lower, $arabizi_lower, false ); } $density = $word_count > 0 ? round( ( $kw_count / $word_count ) * 100, 2 ) : 0; if ( $density >= 0.5 && $density <= 3 ) { $checks[] = self::pass( 'kw_density', "Keyword density is {$density}% - good." ); } elseif ( $density < 0.5 ) { $checks[] = self::warn( 'kw_density', "Keyword density is {$density}% (too low). Use your focus keyword more naturally in the content." ); } else { $checks[] = self::warn( 'kw_density', "Keyword density is {$density}% (too high - may look spammy). Reduce keyword repetition." ); } // 6. Keyword in first paragraph. $paragraphs = preg_split( '/\n\n+/', $content ); $first_para = self::normalize_for_compare( $paragraphs[0] ?? '' ); $checks[] = self::matches_kw( $first_para, $kw_lower, $arabizi_lower ) ? self::pass( 'kw_in_intro', 'Focus keyword appears in the first paragraph.' ) : self::warn( 'kw_in_intro', 'Move your focus keyword to the first paragraph - Google weighs early content more heavily.' ); // 7. Keyword in at least one H2/H3. preg_match_all( '/]*>(.*?)<\/h[23]>/is', $rendered, $headings ); $heading_text = self::normalize_for_compare( implode( ' ', $headings[1] ?? [] ) ); $checks[] = self::matches_kw( $heading_text, $kw_lower, $arabizi_lower ) ? self::pass( 'kw_in_headings', 'Focus keyword found in a subheading (H2/H3).' ) : self::warn( 'kw_in_headings', 'Add your focus keyword to at least one H2 or H3 subheading.' ); } // 8. SEO title length (50–60 chars). $title_len = mb_strlen( $title ); if ( $title_len >= 50 && $title_len <= 60 ) { $checks[] = self::pass( 'title_length', "SEO title is {$title_len} chars - ideal." ); } elseif ( $title_len < 50 ) { $checks[] = self::warn( 'title_length', "SEO title is {$title_len} chars (too short). Aim for 50–60 to use the full SERP display." ); } else { $checks[] = self::warn( 'title_length', "SEO title is {$title_len} chars (too long). Google will truncate above ~60 chars." ); } // 9. Meta description length (120–160 chars). $desc_len = mb_strlen( $desc ); if ( $desc_len >= 120 && $desc_len <= 160 ) { $checks[] = self::pass( 'desc_length', "Meta description is {$desc_len} chars - ideal." ); } elseif ( $desc_len === 0 ) { $checks[] = self::fail( 'desc_length', 'No meta description set. Write one - it appears as the snippet in search results.' ); } elseif ( $desc_len < 120 ) { $checks[] = self::warn( 'desc_length', "Meta description is {$desc_len} chars (too short). Expand to 120–160 chars." ); } else { $checks[] = self::warn( 'desc_length', "Meta description is {$desc_len} chars (too long - may be cut off). Keep it under 160." ); } // 10. Content length (>300 words for basic, >1000 for good). // Unicode-safe count handles Arabic and other non-Latin scripts. $wc = self::count_words( $content ); if ( $wc >= 1000 ) { $checks[] = self::pass( 'content_length', "Content is {$wc} words - substantial." ); } elseif ( $wc >= 300 ) { $checks[] = self::warn( 'content_length', "Content is {$wc} words. Google favors in-depth content - aim for 1,000+ words for competitive keywords." ); } else { $checks[] = self::fail( 'content_length', "Content is only {$wc} words. Thin content ranks poorly. Add more depth (minimum 300 words, ideally 1,000+)." ); } // 11. Images present. preg_match_all( '/]+>/i', $rendered, $imgs ); $img_count = count( $imgs[0] ); $checks[] = $img_count > 0 ? self::pass( 'has_images', "Post contains {$img_count} image(s)." ) : self::warn( 'has_images', 'No images found. Adding relevant images improves engagement and can rank in Google Images.' ); // 12. Alt text on images. if ( $img_count > 0 ) { preg_match_all( '/]+alt=["\']([^"\']*)["\'][^>]*>/i', $rendered, $alts ); $no_alt = $img_count - count( array_filter( $alts[1] ) ); $checks[] = $no_alt === 0 ? self::pass( 'img_alt', 'All images have alt text.' ) : self::warn( 'img_alt', "{$no_alt} image(s) are missing alt text. Alt text helps accessibility and image SEO." ); } // 13. Internal links. preg_match_all( '/]+href=["\']' . preg_quote( home_url(), '/' ) . '[^"\']*["\'][^>]*>/i', $rendered, $int_links ); $int_count = count( $int_links[0] ); $checks[] = $int_count >= 2 ? self::pass( 'internal_links', "Post has {$int_count} internal link(s) - good for link equity." ) : self::warn( 'internal_links', "Only {$int_count} internal link(s) found. Add 2–5 links to related content on your site." ); // 14. External links. preg_match_all( '/]+href=["\']https?:\/\/(?!' . preg_quote( parse_url( home_url(), PHP_URL_HOST ), '/' ) . ')[^"\']+["\'][^>]*>/i', $rendered, $ext_links ); $ext_count = count( $ext_links[0] ); $checks[] = $ext_count > 0 ? self::pass( 'external_links', "Post links out to {$ext_count} external source(s) - good for trust signals." ) : self::warn( 'external_links', 'No external links found. Linking to authoritative sources can boost credibility.' ); // 15. Has H1 (via post title - WordPress always outputs the post title as H1 in well-coded themes). $checks[] = self::pass( 'has_h1', 'Post title is used as the H1 heading.' ); // 16. Permalink readable (no query strings, no numbers-only slugs). $is_clean = ! preg_match( '/[?&=]/', $url ) && ! preg_match( '/\/\?p=\d+/', $url ); $checks[] = $is_clean ? self::pass( 'clean_url', 'URL is clean and readable.' ) : self::warn( 'clean_url', 'URL contains query strings or looks like a default WordPress permalink. Use readable slugs (Settings → Permalinks).' ); // Score. $total = count( $checks ); $passed = count( array_filter( $checks, fn( $c ) => $c['status'] === 'pass' ) ); $score = $total > 0 ? round( ( $passed / $total ) * 100 ) : 0; return [ 'score' => $score, 'checks' => $checks, ]; } private static function pass( $id, $msg ) { return [ 'id' => $id, 'status' => 'pass', 'message' => $msg ]; } private static function warn( $id, $msg ) { return [ 'id' => $id, 'status' => 'warn', 'message' => $msg ]; } private static function fail( $id, $msg ) { return [ 'id' => $id, 'status' => 'fail', 'message' => $msg ]; } }