admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce('shadowsync_seo_scan_nonce'), ]); } public function register_meta_box() { add_meta_box( 'shadowsync_seo_meta_box', '⚡ ShadowSync SEO — SEO Settings', [$this, 'render_meta_box'], ['post', 'page'], 'normal', 'high' ); } public function render_meta_box($post) { wp_nonce_field('shadowsync_seo_save_meta', 'shadowsync_seo_meta_nonce'); $meta_title = get_post_meta($post->ID, '_shadowsync_seo_meta_title', true); $meta_desc = get_post_meta($post->ID, '_shadowsync_seo_meta_description', true); $focus_keyword = get_post_meta($post->ID, '_shadowsync_seo_focus_keyword', true); $social_title = get_post_meta($post->ID, '_shadowsync_seo_social_title', true); $social_desc = get_post_meta($post->ID, '_shadowsync_seo_social_description', true); $page_type = ShadowSync_SEO_Schema::get_type($post->ID, 'page'); $article_type = ShadowSync_SEO_Schema::get_type($post->ID, 'post'); $backup_time = get_post_meta($post->ID, '_shadowsync_seo_content_backup_time', true); if (empty($meta_title)) { $meta_title = get_the_title($post->ID); } $readability = []; $seo_analysis = []; if (!empty($post->post_content)) { $readability = $this->analyse_readability($post->post_content); $seo_analysis = $this->analyse_seo($post, $focus_keyword, $meta_title, $meta_desc); } ?>
/ 60 characters
/ 160 characters
ID)); ?>
post_content; $content_plain = strtolower(wp_strip_all_tags($content)); $content_lower = strtolower($content); $keyword_lower = strtolower(trim($keyword)); // With no keyword the seven keyword checks are meaningless: strpos() // against '' matches at offset 0 and would report false passes, and // substr_count() throws a ValueError on PHP 8. $has_keyword = ('' !== $keyword_lower); if (!$has_keyword) { $problems[] = [ 'label' => 'Focus keyword', 'detail' => 'No focus keyword set. Add one above and the keyword checks will run.', ]; } $title_lower = strtolower($meta_title); $desc_lower = strtolower($meta_desc); $slug = strtolower($post->post_name); $word_count = str_word_count($content_plain); $site_url = home_url(); // ── 1. Outbound links ───────────────────────────── $outbound = preg_match_all('/]+href=["\']https?:\/\/(?!' . preg_quote(wp_parse_url($site_url, PHP_URL_HOST), '/') . ')[^"\']+["\']/i', $content); if ($outbound === 0) { $problems[] = ['label' => 'Outbound links', 'detail' => 'No outbound links found. Link to relevant external sources to show authority.']; } else { $good[] = ['label' => 'Outbound links', 'detail' => $outbound . ' outbound link(s) found. Good job!']; $score += $pts; } // ── 2. Images with alt containing keyword ───────── $img_count = preg_match_all('/]+>/i', $content, $img_matches); if ($img_count === 0) { $problems[] = ['label' => 'Images', 'detail' => 'No images found. Add images with descriptive alt text containing your focus keyword.']; } else { $keyword_alt = 0; foreach ($has_keyword ? $img_matches[0] : [] as $img) { if (preg_match('/alt=["\'][^"\']*' . preg_quote($keyword_lower, '/') . '[^"\']*["\']/i', $img)) { $keyword_alt++; } } if ($keyword_alt === 0) { $improvements[] = ['label' => 'Image alt attributes', 'detail' => 'No images have alt text containing the focus keyword. Add the keyword to at least one image alt tag.']; } else { $good[] = ['label' => 'Image alt attributes', 'detail' => $keyword_alt . ' image(s) have alt text containing the focus keyword.']; $score += $pts; } } // ── 3. Internal links ───────────────────────────── $internal = preg_match_all('/]+href=["\']' . preg_quote($site_url, '/') . '[^"\']*["\']/i', $content); if ($internal === 0) { $problems[] = ['label' => 'Internal links', 'detail' => 'No internal links found. Link to other pages on your site to improve navigation and SEO.']; } else { $good[] = ['label' => 'Internal links', 'detail' => $internal . ' internal link(s) found. Nice!']; $score += $pts; } // ── 4. Keyword in introduction ──────────────────── $paras = preg_split('/\n\s*\n/', $content_plain, -1, PREG_SPLIT_NO_EMPTY); $first_para = strtolower(wp_strip_all_tags($paras[0] ?? '')); if ($has_keyword && strpos($first_para, $keyword_lower) !== false) { $good[] = ['label' => 'Keyphrase in introduction', 'detail' => 'Focus keyword appears in the introduction. Well done!']; $score += $pts; } else { $problems[] = ['label' => 'Keyphrase in introduction', 'detail' => 'Focus keyword not found in the first paragraph. Add it to the introduction.']; } // ── 5. Keyword density ──────────────────────────── $occurrences = $has_keyword ? substr_count($content_plain, $keyword_lower) : 0; $keyword_words = str_word_count($keyword_lower); $density = $word_count > 0 ? round(($occurrences * $keyword_words / $word_count) * 100, 1) : 0; if ($density < 0.5) { $problems[] = ['label' => 'Keyphrase density', 'detail' => 'Keyword density is ' . $density . '% — too low. Aim for 1–2%. Use the keyword more naturally throughout the content.']; } elseif ($density > 3.0) { $improvements[] = ['label' => 'Keyphrase density', 'detail' => 'Keyword density is ' . $density . '% — too high. Reduce usage to avoid keyword stuffing.']; } else { $good[] = ['label' => 'Keyphrase density', 'detail' => 'Keyword density is ' . $density . '% — perfect range.']; $score += $pts; } // ── 6. Keyword in SEO title ─────────────────────── if ($has_keyword && strpos($title_lower, $keyword_lower) !== false) { $good[] = ['label' => 'Keyphrase in SEO title', 'detail' => 'Focus keyword found in the SEO title. Great!']; $score += $pts; } else { $problems[] = ['label' => 'Keyphrase in SEO title', 'detail' => 'Focus keyword not found in SEO title. Add it for better rankings.']; } // ── 7. Keyword in meta description ──────────────── if (empty($meta_desc)) { $problems[] = ['label' => 'Keyphrase in meta description', 'detail' => 'Meta description is missing. Add one containing your focus keyword.']; } elseif ($has_keyword && strpos($desc_lower, $keyword_lower) !== false) { $good[] = ['label' => 'Keyphrase in meta description', 'detail' => 'Focus keyword found in meta description. Well done!']; $score += $pts; } else { $improvements[] = ['label' => 'Keyphrase in meta description', 'detail' => 'Focus keyword not found in meta description. Add it for better click-through rates.']; } // ── 8. Meta description length ──────────────────── $desc_len = strlen($meta_desc); if ($desc_len === 0) { $problems[] = ['label' => 'Meta description length', 'detail' => 'No meta description set. Add one between 50–160 characters.']; } elseif ($desc_len < 50) { $improvements[] = ['label' => 'Meta description length', 'detail' => 'Meta description is too short (' . $desc_len . ' chars). Aim for 50–160 characters.']; } elseif ($desc_len > 160) { $improvements[] = ['label' => 'Meta description length', 'detail' => 'Meta description is too long (' . $desc_len . ' chars). Keep it under 160 characters.']; } else { $good[] = ['label' => 'Meta description length', 'detail' => 'Meta description length is perfect (' . $desc_len . ' characters).']; $score += $pts; } // ── 9. Keyword in slug ──────────────────────────── $keyword_slug = str_replace(' ', '-', $keyword_lower); if ($has_keyword && (strpos($slug, $keyword_slug) !== false || strpos($slug, str_replace(' ', '', $keyword_lower)) !== false)) { $good[] = ['label' => 'Keyphrase in slug', 'detail' => 'Focus keyword found in the URL slug.']; $score += $pts; } else { $improvements[] = ['label' => 'Keyphrase in slug', 'detail' => 'Focus keyword not in the URL slug. Consider updating the slug to include it.']; } // ── 10. Keyword in subheading ───────────────────── $headings_with_kw = $has_keyword ? preg_match_all('/]*>.*?' . preg_quote($keyword_lower, '/') . '.*?<\/h[2-4]>/is', $content_lower) : 0; if ($headings_with_kw > 0) { $good[] = ['label' => 'Keyphrase in subheading', 'detail' => 'Focus keyword found in ' . $headings_with_kw . ' subheading(s).']; $score += $pts; } else { $improvements[] = ['label' => 'Keyphrase in subheading', 'detail' => 'Focus keyword not found in any subheadings. Add it to an H2 or H3 for better SEO.']; } // ── 11. SEO title length ────────────────────────── $title_len = strlen($meta_title); if ($title_len < 30) { $problems[] = ['label' => 'SEO title width', 'detail' => 'SEO title is too short (' . $title_len . ' chars). Aim for 30–60 characters.']; } elseif ($title_len > 60) { $improvements[] = ['label' => 'SEO title width', 'detail' => 'SEO title is too long (' . $title_len . ' chars). Keep it under 60 characters.']; } else { $good[] = ['label' => 'SEO title width', 'detail' => 'SEO title length is perfect (' . $title_len . ' characters).']; $score += $pts; } // ── 12. Text length ─────────────────────────────── if ($word_count < 150) { $problems[] = ['label' => 'Text length', 'detail' => 'Content is too short (' . $word_count . ' words). Aim for at least 300 words for better rankings.']; } elseif ($word_count < 300) { $improvements[] = ['label' => 'Text length', 'detail' => 'Content has ' . $word_count . ' words. Consider expanding to 300+ for better rankings.']; } else { $good[] = ['label' => 'Text length', 'detail' => 'Content has ' . $word_count . ' words. Good length for SEO!']; $score += $pts; } // ── 13. Single H1 ──────────────────────────────── $h1_count = preg_match_all('/]*>/i', $content); if ($h1_count === 0) { $improvements[] = ['label' => 'Single H1', 'detail' => 'No H1 heading found in content. The post title serves as H1 — avoid adding another.']; $score += $pts; // Not penalised since WP title is H1 } elseif ($h1_count === 1) { $good[] = ['label' => 'Single H1', 'detail' => 'One H1 found in content. Good structure!']; $score += $pts; } else { $problems[] = ['label' => 'Single H1', 'detail' => 'Multiple H1 tags found (' . $h1_count . '). Use only one H1 — your post title already serves as H1.']; } return [ 'score' => min(100, $score), 'problems' => $problems, 'improvements' => $improvements, 'good' => $good, ]; } // ── READABILITY ANALYSIS ────────────────────────────── private function analyse_readability($content) { $plain = wp_strip_all_tags($content); $problems = []; $improvements = []; $good = []; $score = 100; $sentences = preg_split('/(?<=[.!?])\s+/', $plain, -1, PREG_SPLIT_NO_EMPTY); $sentences = array_values(array_filter($sentences, function($s) { return str_word_count($s) > 2; })); $total_sentences = count($sentences); $paragraphs = array_filter(preg_split('/\n\s*\n/', $plain, -1, PREG_SPLIT_NO_EMPTY), function($p) { return str_word_count($p) > 5; }); $word_count = str_word_count($plain); // Passive voice $passive_count = 0; foreach ($sentences as $s) { if (preg_match('/\b(is|are|was|were|be|been|being)\s+\w+ed\b/i', $s)) $passive_count++; } $passive_pct = $total_sentences > 0 ? round(($passive_count / $total_sentences) * 100) : 0; if ($passive_pct > 10) { $problems[] = ['label' => 'Passive voice', 'detail' => $passive_pct . '% of sentences use passive voice. Aim for under 10%.']; $score -= 15; } else { $good[] = ['label' => 'Passive voice', 'detail' => 'Only ' . $passive_pct . '% passive voice. Great job!']; } // Sentence length $long_s = count(array_filter($sentences, function($s) { return str_word_count($s) > 20; })); $long_pct = $total_sentences > 0 ? round(($long_s / $total_sentences) * 100) : 0; if ($long_pct > 25) { $problems[] = ['label' => 'Sentence length', 'detail' => $long_pct . '% of sentences are too long. Shorten them.']; $score -= 15; } elseif ($long_pct > 10) { $improvements[] = ['label' => 'Sentence length', 'detail' => $long_pct . '% of sentences over 20 words. Aim for under 10%.']; $score -= 5; } else { $good[] = ['label' => 'Sentence length', 'detail' => 'Most sentences are a good length.']; } // Consecutive sentences $consecutive = 0; $max_consec = 0; $prev = ''; foreach ($sentences as $s) { $w = explode(' ', trim($s)); $start = strtolower($w[0] ?? ''); if ($start === $prev && $start !== '') { $consecutive++; $max_consec = max($max_consec, $consecutive); } else $consecutive = 0; $prev = $start; } if ($max_consec >= 3) { $problems[] = ['label' => 'Consecutive sentences', 'detail' => ($max_consec + 1) . ' consecutive sentences start with the same word. Mix things up.']; $score -= 10; } else { $good[] = ['label' => 'Consecutive sentences', 'detail' => 'No problematic consecutive sentence patterns.']; } // Paragraph length $long_p = count(array_filter($paragraphs, function($p) { return str_word_count($p) > 150; })); if ($long_p > 0) { $problems[] = ['label' => 'Paragraph length', 'detail' => $long_p . ' paragraph(s) over 150 words. Break them up.']; $score -= 10; } else { $good[] = ['label' => 'Paragraph length', 'detail' => 'All paragraphs are a good length.']; } // Subheadings $h_count = preg_match_all('/]*>/i', $content); if ($word_count > 300 && $h_count === 0) { $problems[] = ['label' => 'Subheading distribution', 'detail' => 'No subheadings found. Add H2/H3 every 300 words.']; $score -= 15; } elseif ($word_count > 600 && $h_count < 2) { $improvements[] = ['label' => 'Subheading distribution', 'detail' => 'Consider adding more subheadings.']; $score -= 5; } else { $good[] = ['label' => 'Subheading distribution', 'detail' => 'Good use of subheadings.']; } // Transition words $tw = ['however','therefore','furthermore','moreover','consequently','additionally', 'nevertheless','meanwhile','because','although','despite','whereas','similarly', 'in addition','as a result','in contrast','for example','in conclusion', 'finally','lastly','next','then','first','second']; $tc = 0; $pl = strtolower($plain); foreach ($tw as $w) { if (strpos($pl, $w) !== false) $tc++; } $t_pct = $total_sentences > 0 ? round(($tc / $total_sentences) * 100) : 0; if ($t_pct < 20) { $improvements[] = ['label' => 'Transition words', 'detail' => 'Only ' . $tc . ' transition word(s). Add words like "however", "therefore", "in addition".']; $score -= 10; } else { $good[] = ['label' => 'Transition words', 'detail' => 'Good use of transition words.']; } // Text length if ($word_count < 150) { $problems[] = ['label' => 'Text length', 'detail' => 'Content is very short (' . $word_count . ' words). Aim for 300+.']; $score -= 15; } elseif ($word_count >= 300) { $good[] = ['label' => 'Text length', 'detail' => 'The text contains ' . $word_count . ' words. Good job!']; } else { $improvements[] = ['label' => 'Text length', 'detail' => 'Content has ' . $word_count . ' words. Consider expanding to 300+.']; $score -= 5; } $score = max(0, $score); $grade = $score >= 70 ? 'Good — Easy to read' : ($score >= 40 ? 'OK — Needs some improvement' : 'Poor — Needs significant work'); return compact('score', 'grade', 'problems', 'improvements', 'good'); } // ── SAVE META BOX ───────────────────────────────────── public function save_meta_box($post_id, $post) { if (!isset($_POST['shadowsync_seo_meta_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['shadowsync_seo_meta_nonce'])), 'shadowsync_seo_save_meta')) return; if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return; if (!current_user_can('edit_post', $post_id)) return; $text_fields = [ 'shadowsync_seo_meta_title' => '_shadowsync_seo_meta_title', 'shadowsync_seo_social_title' => '_shadowsync_seo_social_title', 'shadowsync_seo_focus_keyword' => '_shadowsync_seo_focus_keyword', ]; foreach ($text_fields as $post_key => $meta_key) { if (isset($_POST[$post_key])) { update_post_meta($post_id, $meta_key, sanitize_text_field( wp_unslash( $_POST[$post_key] ) )); } } // Schema types are stored only if they are one of the offered choices. $type_fields = [ 'shadowsync_seo_page_type' => ['_shadowsync_seo_page_type', ShadowSync_SEO_Schema::PAGE_TYPES], 'shadowsync_seo_article_type' => ['_shadowsync_seo_article_type', ShadowSync_SEO_Schema::ARTICLE_TYPES], ]; foreach ($type_fields as $post_key => list($meta_key, $allowed)) { if (isset($_POST[$post_key])) { $type = sanitize_text_field( wp_unslash( $_POST[$post_key] ) ); if (in_array($type, $allowed, true)) { update_post_meta($post_id, $meta_key, $type); } } } foreach (['shadowsync_seo_meta_description' => '_shadowsync_seo_meta_description', 'shadowsync_seo_social_description' => '_shadowsync_seo_social_description'] as $k => $v) { if (isset($_POST[$k])) { update_post_meta($post_id, $v, sanitize_textarea_field( wp_unslash( $_POST[$k] ) )); } } } }