post_content; $text = wp_strip_all_tags( $content ); $word_count = str_word_count( $text ); $issues = array(); preg_match_all( '/]*>/i', $content, $h2 ); preg_match_all( '/]*>/i', $content, $h3 ); $h2_count = count( $h2[0] ); $h3_count = count( $h3[0] ); preg_match_all( '/]*>(.*?)<\/p>/si', $content, $p_matches ); $html_paras = array_filter( array_map( 'strip_tags', $p_matches[1] ), function( $p ) { return strlen( trim( $p ) ) > 20; } ); $para_count = count( $html_paras ); if ( $para_count === 0 ) { $fallback = array_filter( array_map( 'trim', preg_split( '/\n{2,}/', $text ) ), function( $p ) { return strlen( $p ) > 20; } ); $para_count = count( $fallback ); } preg_match_all( '/]+href=["\']?([^"\'> ]+)["\']?/i', $content, $link_matches ); $internal_links = 0; $external_links = 0; $home_norm = preg_replace( '/^https?:/', '', home_url() ); foreach ( $link_matches[1] as $href ) { $href_norm = preg_replace( '/^https?:/', '', $href ); if ( strpos( $href_norm, $home_norm ) !== false || strpos( $href, '/' ) === 0 ) { $internal_links++; } elseif ( strpos( $href, 'http' ) === 0 ) { $external_links++; } } $has_list = ( strpos( $content, ' 2; } ); $sentence_count = count( $sentences ); $avg_words_per_sentence = $sentence_count > 0 ? round( $word_count / $sentence_count ) : 0; preg_match_all( '/]+>/i', $content, $img_matches ); $img_count = count( $img_matches[0] ); $imgs_with_alt = 0; foreach ( $img_matches[0] as $img ) { if ( preg_match( '/alt=["\'][^"\']+["\']/i', $img ) ) { $imgs_with_alt++; } } $has_featured_image = has_post_thumbnail( $post->ID ); $meta_desc = get_post_meta( $post->ID, '_yoast_wpseo_metadesc', true ); if ( empty( $meta_desc ) ) { $meta_desc = get_post_meta( $post->ID, 'rank_math_description', true ); } if ( empty( $meta_desc ) ) { $meta_desc = get_post_meta( $post->ID, '_aioseo_description', true ); } if ( empty( $meta_desc ) ) { $meta_desc = get_post_meta( $post->ID, '_aije_metadesc', true ); } $has_meta_desc = ! empty( $meta_desc ); $job_title = get_user_meta( $post->post_author, self::USER_META_JOB_TITLE, true ); $expertise = get_user_meta( $post->post_author, self::USER_META_EXPERTISE, true ); $expert_url = get_user_meta( $post->post_author, self::USER_META_WEBSITE, true ); $same_as_raw = get_user_meta( $post->post_author, self::USER_META_SAME_AS, true ); $same_as_count = 0; if ( ! empty( $same_as_raw ) ) { foreach ( preg_split( "/\r\n|\n|\r/", $same_as_raw ) as $line ) { $line = trim( $line ); if ( $line !== '' && substr( $line, 0, 1 ) !== '#' ) { $same_as_count++; } } } $is_recent = strtotime( $post->post_modified ) > strtotime( '-12 months' ); $title = $post->post_title; $title_len = mb_strlen( $title ); $title_has_nr = (bool) preg_match( '/\d/', $title ); $title_has_q = ( strpos( $title, '?' ) !== false ); // ── Content-Qualität ────────────────────────────────────────────────── $c_score = 0; if ( $word_count >= 1500 ) { $c_score += 40; } elseif ( $word_count >= 1000 ) { $c_score += 32; } elseif ( $word_count >= 700 ) { $c_score += 22; } elseif ( $word_count >= 400 ) { $c_score += 12; } elseif ( $word_count >= 200 ) { $c_score += 5; } else { $issues[] = array( 'key' => 'content_too_short', 'msg' => sprintf( __( 'Inhalt zu kurz (%d Wörter).', 'ai-json-extractor-auto-embed-lite' ), $word_count ), 'action' => __( 'Mindestens 700 Wörter anstreben.', 'ai-json-extractor-auto-embed-lite' ), 'points' => 30, 'category' => 'content', 'severity' => 'high' ); } if ( $word_count > 0 && $word_count < 700 ) { $issues[] = array( 'key' => 'content_slightly_short', 'msg' => sprintf( __( 'Artikel etwas kurz (%d Wörter).', 'ai-json-extractor-auto-embed-lite' ), $word_count ), 'action' => __( 'Erweitere auf mindestens 700 Wörter.', 'ai-json-extractor-auto-embed-lite' ), 'points' => 18, 'category' => 'content', 'severity' => 'medium' ); } if ( $h2_count >= 4 ) { $c_score += 25; } elseif ( $h2_count >= 2 ) { $c_score += 18; } elseif ( $h2_count === 1 ) { $c_score += 10; } else { $issues[] = array( 'key' => 'no_h2', 'msg' => __( 'Keine H2-Zwischenüberschriften.', 'ai-json-extractor-auto-embed-lite' ), 'action' => __( 'Mindestens 3 H2-Überschriften.', 'ai-json-extractor-auto-embed-lite' ), 'points' => 20, 'category' => 'content', 'severity' => 'high' ); } if ( $h3_count >= 2 ) { $c_score += 5; } if ( $avg_words_per_sentence >= 10 && $avg_words_per_sentence <= 20 ) { $c_score += 15; } elseif ( $avg_words_per_sentence > 20 && $avg_words_per_sentence <= 28 ) { $c_score += 8; $issues[] = array( 'key' => 'sentence_length_high', 'msg' => sprintf( __( 'Satzlänge leicht erhöht (%d Wörter/Satz).', 'ai-json-extractor-auto-embed-lite' ), $avg_words_per_sentence ), 'action' => __( 'Zielwert: 12–18 Wörter.', 'ai-json-extractor-auto-embed-lite' ), 'points' => 7, 'category' => 'content', 'severity' => 'low' ); } elseif ( $avg_words_per_sentence > 28 ) { $issues[] = array( 'key' => 'sentence_length_very_high', 'msg' => sprintf( __( 'Sätze sehr lang (%d Wörter/Satz).', 'ai-json-extractor-auto-embed-lite' ), $avg_words_per_sentence ), 'action' => __( 'Kürzere Sätze anstreben.', 'ai-json-extractor-auto-embed-lite' ), 'points' => 15, 'category' => 'content', 'severity' => 'medium' ); } elseif ( $avg_words_per_sentence > 0 ) { $c_score += 10; } if ( $has_list ) { $c_score += 7; } else { $issues[] = array( 'key' => 'no_lists', 'msg' => __( 'Keine Aufzählungslisten.', 'ai-json-extractor-auto-embed-lite' ), 'action' => __( 'Mindestens eine ul/ol-Liste.', 'ai-json-extractor-auto-embed-lite' ), 'points' => 5, 'category' => 'content', 'severity' => 'low' ); } if ( $has_faq ) { $c_score += 3; } if ( $para_count >= 6 ) { $c_score += 10; } elseif ( $para_count >= 3 ) { $c_score += 6; } else { $issues[] = array( 'key' => 'too_few_paragraphs', 'msg' => sprintf( __( 'Zu wenige Absätze (%d).', 'ai-json-extractor-auto-embed-lite' ), $para_count ), 'action' => __( 'Mindestens 5–6 Absätze.', 'ai-json-extractor-auto-embed-lite' ), 'points' => 8, 'category' => 'content', 'severity' => 'medium' ); } $c_score = min( 100, $c_score ); // ── E-E-A-T / Autorität ─────────────────────────────────────────────── $e_score = 0; if ( ! empty( $job_title ) ) { $e_score += 20; } else { $issues[] = array( 'key' => 'no_job_title', 'msg' => __( 'Keine Berufsbezeichnung im Autorenprofil.', 'ai-json-extractor-auto-embed-lite' ), 'action' => __( 'Unter Benutzer → Profil hinterlegen.', 'ai-json-extractor-auto-embed-lite' ), 'points' => 20, 'category' => 'eeat', 'severity' => 'high' ); } if ( ! empty( $expertise ) && mb_strlen( $expertise ) >= 80 ) { $e_score += 20; } elseif ( ! empty( $expertise ) ) { $e_score += 10; $issues[] = array( 'key' => 'bio_too_short', 'msg' => sprintf( __( 'Fachbiografie sehr kurz (%d Zeichen).', 'ai-json-extractor-auto-embed-lite' ), mb_strlen( $expertise ) ), 'action' => __( 'Auf mindestens 80 Zeichen ausbauen.', 'ai-json-extractor-auto-embed-lite' ), 'points' => 10, 'category' => 'eeat', 'severity' => 'medium' ); } else { $issues[] = array( 'key' => 'no_bio', 'msg' => __( 'Keine Fachbiografie im Autorenprofil.', 'ai-json-extractor-auto-embed-lite' ), 'action' => __( 'Kurze Fachbiografie eintragen.', 'ai-json-extractor-auto-embed-lite' ), 'points' => 20, 'category' => 'eeat', 'severity' => 'high' ); } if ( $same_as_count >= 3 ) { $e_score += 25; } elseif ( $same_as_count === 2 ) { $e_score += 18; } elseif ( $same_as_count === 1 ) { $e_score += 10; } else { $issues[] = array( 'key' => 'no_sameas', 'msg' => __( 'Keine SameAs-Links.', 'ai-json-extractor-auto-embed-lite' ), 'action' => __( 'Instagram, LinkedIn o.ä. hinterlegen.', 'ai-json-extractor-auto-embed-lite' ), 'points' => 25, 'category' => 'eeat', 'severity' => 'high' ); } if ( ! empty( $expert_url ) ) { $e_score += 15; } else { $issues[] = array( 'key' => 'no_expert_website', 'msg' => __( 'Keine Experten-/Praxiswebsite hinterlegt.', 'ai-json-extractor-auto-embed-lite' ), 'action' => __( 'URL im Benutzerprofil eintragen.', 'ai-json-extractor-auto-embed-lite' ), 'points' => 15, 'category' => 'eeat', 'severity' => 'medium' ); } if ( $external_links >= 3 ) { $e_score += 20; } elseif ( $external_links === 2 ) { $e_score += 15; } elseif ( $external_links === 1 ) { $e_score += 8; } else { $issues[] = array( 'key' => 'no_external_links', 'msg' => __( 'Keine externen Quellen verlinkt.', 'ai-json-extractor-auto-embed-lite' ), 'action' => __( '1–3 vertrauenswürdige Quellen verlinken.', 'ai-json-extractor-auto-embed-lite' ), 'points' => 15, 'category' => 'eeat', 'severity' => 'medium' ); } $e_score = min( 100, $e_score ); // ── Technische SEO ──────────────────────────────────────────────────── $t_score = 0; if ( $has_featured_image ) { $t_score += 30; } else { $issues[] = array( 'key' => 'no_featured_image', 'msg' => __( 'Kein Beitragsbild gesetzt.', 'ai-json-extractor-auto-embed-lite' ), 'action' => __( 'Featured Image setzen.', 'ai-json-extractor-auto-embed-lite' ), 'points' => 30, 'category' => 'technical', 'severity' => 'high' ); } if ( $img_count === 0 ) { $issues[] = array( 'key' => 'no_content_images', 'msg' => __( 'Keine Bilder im Artikelinhalt.', 'ai-json-extractor-auto-embed-lite' ), 'action' => __( 'Mindestens 1–2 Bilder einfügen.', 'ai-json-extractor-auto-embed-lite' ), 'points' => 15, 'category' => 'technical', 'severity' => 'low' ); } elseif ( $imgs_with_alt === $img_count ) { $t_score += 25; } elseif ( $imgs_with_alt > 0 ) { $t_score += 12; $issues[] = array( 'key' => 'images_missing_alt_partial', 'msg' => sprintf( __( '%d Bild(er) ohne Alt-Text.', 'ai-json-extractor-auto-embed-lite' ), $img_count - $imgs_with_alt ), 'action' => __( 'Alt-Texte ergänzen.', 'ai-json-extractor-auto-embed-lite' ), 'points' => 13, 'category' => 'technical', 'severity' => 'medium' ); } else { $issues[] = array( 'key' => 'images_missing_alt_all', 'msg' => sprintf( __( 'Alle %d Bilder ohne Alt-Text.', 'ai-json-extractor-auto-embed-lite' ), $img_count ), 'action' => __( 'Alt-Texte für alle Bilder ergänzen.', 'ai-json-extractor-auto-embed-lite' ), 'points' => 25, 'category' => 'technical', 'severity' => 'high' ); } if ( $has_meta_desc ) { $meta_len = mb_strlen( $meta_desc ); if ( $meta_len >= 120 && $meta_len <= 160 ) { $t_score += 25; } elseif ( $meta_len >= 70 ) { $t_score += 15; $issues[] = array( 'key' => 'meta_desc_length_suboptimal', 'msg' => sprintf( __( 'Meta-Description Länge nicht optimal (%d Zeichen).', 'ai-json-extractor-auto-embed-lite' ), $meta_len ), 'action' => __( 'Zielbereich: 120–160 Zeichen.', 'ai-json-extractor-auto-embed-lite' ), 'points' => 10, 'category' => 'technical', 'severity' => 'low' ); } else { $t_score += 8; $issues[] = array( 'key' => 'meta_desc_too_short', 'msg' => sprintf( __( 'Meta-Description zu kurz (%d Zeichen).', 'ai-json-extractor-auto-embed-lite' ), $meta_len ), 'action' => __( 'Auf 120–160 Zeichen ausbauen.', 'ai-json-extractor-auto-embed-lite' ), 'points' => 17, 'category' => 'technical', 'severity' => 'medium' ); } } else { $issues[] = array( 'key' => 'no_meta_desc', 'msg' => __( 'Keine Meta-Description gefunden.', 'ai-json-extractor-auto-embed-lite' ), 'action' => __( 'Im Meta-Generator oder Yoast/RankMath hinterlegen.', 'ai-json-extractor-auto-embed-lite' ), 'points' => 25, 'category' => 'technical', 'severity' => 'high' ); } if ( $title_len >= 50 && $title_len <= 65 ) { $t_score += 20; } elseif ( $title_len >= 35 ) { $t_score += 12; if ( $title_len > 65 ) { $issues[] = array( 'key' => 'title_too_long', 'msg' => sprintf( __( 'Titel zu lang (%d Zeichen).', 'ai-json-extractor-auto-embed-lite' ), $title_len ), 'action' => __( 'Auf max. 65 Zeichen kürzen.', 'ai-json-extractor-auto-embed-lite' ), 'points' => 8, 'category' => 'technical', 'severity' => 'low' ); } } else { $issues[] = array( 'key' => 'title_too_short', 'msg' => sprintf( __( 'Titel sehr kurz (%d Zeichen).', 'ai-json-extractor-auto-embed-lite' ), $title_len ), 'action' => __( 'Auf 50–65 Zeichen ausbauen.', 'ai-json-extractor-auto-embed-lite' ), 'points' => 15, 'category' => 'technical', 'severity' => 'medium' ); } if ( $title_has_nr || $title_has_q ) { $t_score += 5; } $t_score = min( 100, $t_score ); // ── Interne Vernetzung ──────────────────────────────────────────────── $l_score = 0; if ( $internal_links >= 4 ) { $l_score += 60; } elseif ( $internal_links === 3 ) { $l_score += 50; } elseif ( $internal_links === 2 ) { $l_score += 38; } elseif ( $internal_links === 1 ) { $l_score += 22; } // Hinweis + "Wie verbessere ich das konkret?"-Button bleiben sichtbar, bis die // empfohlene Link-Anzahl erreicht ist — nicht nur solange 0 Links gesetzt // sind. Vorher verschwand das Issue (und damit der Button/das Modal für // "Link einfügen") komplett nach dem allerersten gesetzten Link, obwohl laut // Faustregel (~1 Link/200–250 Wörter) meist noch weitere sinnvoll wären. $l_recommended = $this->recommended_internal_links( $word_count ); if ( $internal_links < $l_recommended ) { $issues[] = ( 0 === $internal_links ) ? array( 'key' => 'no_internal_links', 'msg' => __( 'Keine internen Links.', 'ai-json-extractor-auto-embed-lite' ), 'action' => __( '2–4 thematisch verwandte Artikel verlinken.', 'ai-json-extractor-auto-embed-lite' ), 'points' => 50, 'category' => 'linking', 'severity' => 'high' ) : array( 'key' => 'no_internal_links', 'msg' => sprintf( __( 'Erst %1$d von ~%2$d empfohlenen internen Links.', 'ai-json-extractor-auto-embed-lite' ), $internal_links, $l_recommended ), 'action' => __( 'Weitere thematisch verwandte Artikel verlinken.', 'ai-json-extractor-auto-embed-lite' ), 'points' => 20, 'category' => 'linking', 'severity' => 'low' ); } if ( $is_recent ) { $l_score += 40; } else { $age_months = round( ( time() - strtotime( $post->post_modified ) ) / MONTH_IN_SECONDS ); $issues[] = array( 'key' => 'stale_content', 'msg' => sprintf( __( 'Artikel seit %d Monaten nicht aktualisiert.', 'ai-json-extractor-auto-embed-lite' ), $age_months ), 'action' => __( 'Inhalte, Zahlen und Links prüfen.', 'ai-json-extractor-auto-embed-lite' ), 'points' => 35, 'category' => 'linking', 'severity' => 'medium' ); } $l_score = min( 100, $l_score ); // ── Gewichteter Gesamt-Score ────────────────────────────────────────── $weights = self::get_score_weights( $this->get_opts() ); $total = (int) round( ( $c_score * $weights['content'] / 100 ) + ( $e_score * $weights['eeat'] / 100 ) + ( $t_score * $weights['technical'] / 100 ) + ( $l_score * $weights['linking'] / 100 ) ); $sev_order = array( 'high' => 0, 'medium' => 1, 'low' => 2 ); usort( $issues, function( $a, $b ) use ( $sev_order ) { $s = $sev_order[ $a['severity'] ] <=> $sev_order[ $b['severity'] ]; return $s !== 0 ? $s : ( $b['points'] <=> $a['points'] ); } ); return array( 'score' => $total, 'categories' => array( 'content' => array( 'score' => $c_score, 'label' => __( 'Content-Qualität', 'ai-json-extractor-auto-embed-lite' ), 'weight' => $weights['content'] ), 'eeat' => array( 'score' => $e_score, 'label' => 'E-E-A-T / ' . __( 'Autorität', 'ai-json-extractor-auto-embed-lite' ), 'weight' => $weights['eeat'] ), 'technical' => array( 'score' => $t_score, 'label' => __( 'Technische SEO', 'ai-json-extractor-auto-embed-lite' ), 'weight' => $weights['technical'] ), 'linking' => array( 'score' => $l_score, 'label' => __( 'Interne Vernetzung', 'ai-json-extractor-auto-embed-lite' ), 'weight' => $weights['linking'] ), ), 'issues' => $issues, ); } private function calculate_ai_score( $post ) { $analysis = $this->calculate_ai_analysis( $post ); return isset( $analysis['score'] ) ? (int) $analysis['score'] : 0; } private function find_related_posts( $post, $limit = 6 ) { $stopwords = array( 'der','die','das','den','dem','des','ein','eine','einer','eines','und','oder','aber','doch','wenn','weil','dass','damit','dann','ist','sind','war','waren','hat','haben','wird','werden','wurde','worden','kann','muss','soll','darf','will','sein','nicht','auch','noch','nur','schon','sehr','mehr','viel','alle','beide','hier','dort','jetzt','ich','du','er','sie','es','wir','ihr','man','sich','mit','von','zu','bei','in','an','auf','aus','nach','the','and','for','with','have','will','they','been','were','would','could','should','which','what','when','where','who','how','more','also','some','are','was','has','not','but','you','can' ); $source_text = mb_strtolower( get_the_title( $post ) . ' ' . wp_strip_all_tags( $post->post_content ) ); $source_text = preg_replace( '/[^a-z\xc3\xa4\xc3\xb6\xc3\xbc\xc3\x9f\s]/u', ' ', $source_text ); $source_words = array_filter( preg_split( '/\s+/', $source_text, -1, PREG_SPLIT_NO_EMPTY ), function( $w ) use ( $stopwords ) { return mb_strlen( $w ) >= 4 && ! in_array( $w, $stopwords ); } ); $freq = array_count_values( $source_words ); arsort( $freq ); $keywords = array_keys( array_slice( $freq, 0, 20, true ) ); if ( empty( $keywords ) ) { return array(); } $o = $this->get_opts(); $post_types = $o['enabled_post_types']; $candidates = array(); foreach ( array_slice( $keywords, 0, 3 ) as $kw ) { $q = new WP_Query( array( 'post_type' => $post_types, 'post_status' => 'publish', 'posts_per_page' => 20, 's' => $kw, 'post__not_in' => array( $post->ID ), 'no_found_rows' => true, 'fields' => 'ids' ) ); foreach ( $q->posts as $id ) { if ( ! isset( $candidates[ $id ] ) ) { $candidates[ $id ] = 0; } } } if ( empty( $candidates ) ) { $q = new WP_Query( array( 'post_type' => $post_types, 'post_status' => 'publish', 'posts_per_page' => 10, 'post__not_in' => array( $post->ID ), 'no_found_rows' => true, 'fields' => 'ids' ) ); foreach ( $q->posts as $id ) { $candidates[ $id ] = 0; } } $scored = array(); foreach ( array_keys( $candidates ) as $cid ) { $cp = get_post( $cid ); if ( ! $cp ) { continue; } $c_text = mb_strtolower( get_the_title( $cid ) . ' ' . wp_strip_all_tags( $cp->post_content ) ); $c_title = mb_strtolower( get_the_title( $cid ) ); $match = 0; foreach ( $keywords as $kw ) { if ( mb_strpos( $c_text, $kw ) !== false ) { $match++; } if ( mb_strpos( $c_title, $kw ) !== false ) { $match++; } } if ( $match > 0 ) { $scored[] = array( 'id' => $cid, 'title' => get_the_title( $cid ), 'url' => get_permalink( $cid ), 'score' => $match, 'excerpt' => wp_trim_words( wp_strip_all_tags( $cp->post_content ), 12, '...' ) ); } } usort( $scored, function( $a, $b ) { return $b['score'] <=> $a['score']; } ); return array_slice( $scored, 0, $limit ); } /** * Vergleicht einen href-Wert (relativ oder absolut, http/https) mit einer * Ziel-URL rein über den Pfad — robust gegen Protokoll- und * Trailing-Slash-Unterschiede, analog zur internal_links-Erkennung in * calculate_ai_analysis(). */ private function href_matches_url( $href, $target_url ) { $target_path = untrailingslashit( (string) wp_parse_url( $target_url, PHP_URL_PATH ) ); if ( '' === $target_path ) { return false; } $href_abs = ( 0 === strpos( $href, 'http' ) ) ? $href : home_url( $href ); $href_path = untrailingslashit( (string) wp_parse_url( $href_abs, PHP_URL_PATH ) ); return '' !== $href_path && $href_path === $target_path; } /** * Zählt, auf wie viele ANDERE Beiträge/Seiten der eigenen Website ein * Beitrag bereits verlinkt (ausgehende interne Links) — dieselbe Logik * wie die internal_links-Erkennung in calculate_ai_analysis(), nur für * einen beliebigen Beitrag statt nur den aktuell analysierten. */ private function count_internal_links( $post_id ) { $p = get_post( $post_id ); if ( ! $p ) { return 0; } preg_match_all( '/]+href=["\']?([^"\'> ]+)["\']?/i', $p->post_content, $link_matches ); $home_norm = preg_replace( '/^https?:/', '', home_url() ); $count = 0; foreach ( $link_matches[1] as $href ) { $href_norm = preg_replace( '/^https?:/', '', $href ); if ( strpos( $href_norm, $home_norm ) !== false || strpos( $href, '/' ) === 0 ) { $count++; } } return $count; } /** * Empfohlene Anzahl ausgehender interner Links für eine Wortanzahl. * Faustregel (SEO-Branchenkonsens, z.B. Wellows/LinkStorm): ca. 1 * interner Link pro 200–250 Wörter. Untergrenze 2 (auch ein kurzer * Beitrag sollte mindestens auf 1–2 verwandte Artikel verweisen), * Obergrenze 20 (mehr wirkt auch bei sehr langen Artikeln wie * Linkspam statt Mehrwert). */ private function recommended_internal_links( $word_count ) { if ( $word_count <= 0 ) { return 0; } $recommended = (int) round( $word_count / 250 ); return max( 2, min( 20, $recommended ) ); } /** * Kombiniert Link-Zählung und Empfehlung für einen beliebigen Beitrag: * aktuelle ausgehende interne Links, Wortanzahl, Empfehlung laut * Faustregel und wie viele davon noch "sinnvoll offen" sind. */ private function get_link_stats( $post_id ) { $p = get_post( $post_id ); if ( ! $p ) { return array( 'links' => 0, 'word_count' => 0, 'recommended' => 0, 'remaining' => 0 ); } $links = $this->count_internal_links( $post_id ); $word_count = str_word_count( wp_strip_all_tags( $p->post_content ) ); $recommended = $this->recommended_internal_links( $word_count ); return array( 'links' => $links, 'word_count' => $word_count, 'recommended' => $recommended, 'remaining' => max( 0, $recommended - $links ), ); } /** * "Wer verlinkt hierher?" — site-weiter Scan, welche anderen Beiträge * bereits einen Link auf $post enthalten, plus thematisch passende * Beiträge (via find_related_posts), die noch NICHT verlinken. */ private function get_inbound_link_report( $post, $related_limit = 8 ) { $target_url = get_permalink( $post ); $o = $this->get_opts(); $linking_from = array(); // Cap bei 300 Beiträgen — für die allermeisten Websites vollständig, // verhindert aber unbegrenzte Laufzeit auf sehr großen Installationen. $candidates = get_posts( array( 'post_type' => $o['enabled_post_types'], 'post_status' => 'publish', 'posts_per_page' => 300, 'post__not_in' => array( $post->ID ), 'orderby' => 'date', 'order' => 'DESC', ) ); foreach ( $candidates as $cp ) { preg_match_all( '/]+href=["\']?([^"\'> ]+)["\']?/i', $cp->post_content, $m ); foreach ( $m[1] as $href ) { if ( $this->href_matches_url( $href, $target_url ) ) { $linking_from[] = array( 'id' => $cp->ID, 'title' => get_the_title( $cp ) ); break; } } } $related = $this->find_related_posts( $post, $related_limit ); $linking_ids = wp_list_pluck( $linking_from, 'id' ); $missing_related = array_values( array_filter( $related, function( $r ) use ( $linking_ids ) { return ! in_array( $r['id'], $linking_ids, true ); } ) ); return array( 'linking_from' => $linking_from, 'missing_related' => $missing_related, ); } private function quick_relevance_score( $post, $target_id ) { $target = get_post( $target_id ); if ( ! $target ) { return 0; } $stopwords = array( 'der','die','das','den','dem','des','ein','eine','einer','eines','und','oder','aber','wenn','weil','dass','dann','ist','sind','hat','haben','wird','werden','kann','muss','sein','nicht','auch','noch','nur','sehr','mehr','alle','hier','dort','mit','von','zu','bei','auf','aus','nach','the','and','for','with','are','was' ); $src_text = mb_strtolower( get_the_title( $post ) . ' ' . mb_substr( wp_strip_all_tags( $post->post_content ), 0, 500 ) ); $src_words = array_unique( array_filter( preg_split( '/\s+/', $src_text, -1, PREG_SPLIT_NO_EMPTY ), function( $w ) use ( $stopwords ) { return mb_strlen( $w ) >= 5 && ! in_array( $w, $stopwords ); } ) ); $tgt_text = mb_strtolower( get_the_title( $target_id ) . ' ' . mb_substr( wp_strip_all_tags( $target->post_content ), 0, 300 ) ); $tgt_words = array_unique( array_filter( preg_split( '/\s+/', $tgt_text, -1, PREG_SPLIT_NO_EMPTY ), function( $w ) use ( $stopwords ) { return mb_strlen( $w ) >= 5 && ! in_array( $w, $stopwords ); } ) ); return count( array_intersect( $src_words, $tgt_words ) ); } /** * Baut die Modal-Daten für die interne-Links-Empfehlung. Wird sowohl vom * 'no_internal_links'-Issue aufgerufen (mit Titel/Farbe aus dem Issue) als auch * für den IMMER sichtbaren Vorschlags-Button in der Sidebar (mit eigenem Titel) * — damit der Button (und das "Link einfügen"-Modal) auch dann nutzbar bleibt, * wenn die empfohlene Link-Anzahl schon erreicht und daher kein Issue mehr aktiv * ist. Vorher verschwand der Button komplett, sobald das Ziel erreicht war. */ private function build_internal_links_advice( $post, $title = null, $subtitle = '', $color = '#e67e22' ) { $D = 'ai-json-extractor-auto-embed-lite'; $post_title = get_the_title( $post ); $title_short = mb_strlen( $post_title ) > 50 ? mb_substr( $post_title, 0, 50 ) . '…' : $post_title; $link_stats = $this->get_link_stats( $post->ID ); $related = $this->find_related_posts( $post, 6 ); // Bereits verlinkte Vorschläge rausfiltern — sonst taucht im Modal (das jetzt // dauerhaft nutzbar bleibt) immer wieder derselbe Artikel auf, auf den gerade // schon verlinkt wurde. if ( ! empty( $related ) ) { preg_match_all( '/]+href=["\']?([^"\'> ]+)["\']?/i', $post->post_content, $out_links ); $related = array_values( array_filter( $related, function( $r ) use ( $out_links ) { foreach ( $out_links[1] as $href ) { if ( $this->href_matches_url( $href, $r['url'] ) ) { return false; } } return true; } ) ); } $advice = array( 'title' => $title ?? __( 'Interne Links', $D ), 'subtitle' => $subtitle, 'color' => $color, 'why' => __( 'Interne Links verteilen Autorität und helfen Google deine Website-Struktur zu verstehen.', $D ), 'checklist' => array( __( '2–3 der vorgeschlagenen Artikel auswählen', $D ), __( 'Links an passenden Stellen einfügen', $D ), __( 'Ein Link früh, einer am Ende', $D ), __( 'Orphan-Content vermeiden', $D ) ), 'tip' => __( 'Die stärksten internen Links kommen von Seiten mit dem meisten Traffic.', $D ), ); if ( 0 === $link_stats['links'] ) { $advice['situation'] = sprintf( __( '„%s" verlinkt noch auf keine anderen Seiten.', $D ), $title_short ); } elseif ( $link_stats['links'] < $link_stats['recommended'] ) { $advice['situation'] = sprintf( _n( '„%1$s" verlinkt bisher auf %2$d anderen Beitrag – empfohlen sind ~%3$d.', '„%1$s" verlinkt bisher auf %2$d andere Beiträge – empfohlen sind ~%3$d.', $link_stats['links'], $D ), $title_short, $link_stats['links'], $link_stats['recommended'] ); } else { $advice['situation'] = sprintf( __( '„%1$s" hat die empfohlene Link-Anzahl (~%2$d) bereits erreicht — weitere thematisch passende Links schaden aber nicht.', $D ), $title_short, $link_stats['recommended'] ); } $advice['example'] = ! empty( $related ) ? __( 'Passende Artikel gefunden (siehe unten). Klicke auf „Link einfügen".', $D ) : "✓ „" . __( 'Mehr dazu: [Titel]', $D ) . "\"\n✓ „" . __( 'Wie wir in [Titel] beschreiben...', $D ) . "\"\n✗ " . __( 'Vermeide: „Hier klicken"', $D ); if ( ! empty( $related ) ) { $advice['related_posts'] = $related; } return $advice; } private function build_issue_advice( $issue, $post, $analysis ) { $D = 'ai-json-extractor-auto-embed-lite'; $title = get_the_title( $post ); $text = wp_strip_all_tags( $post->post_content ); $word_count = str_word_count( $text ); $category = $issue['category']; $severity = $issue['severity']; $issue_key = $issue['key'] ?? ''; $title_short = mb_strlen( $title ) > 50 ? mb_substr( $title, 0, 50 ) . '…' : $title; $sev_label = array( 'high' => '🔴 ' . __( 'Hoher Einfluss', $D ), 'medium' => '🟡 ' . __( 'Mittlerer Einfluss', $D ), 'low' => '🟢 ' . __( 'Geringer Einfluss', $D ) )[ $severity ] ?? ''; $cat_color = array( 'content' => '#3498db', 'eeat' => '#9b59b6', 'technical' => '#1abc9c', 'linking' => '#e67e22' )[ $category ] ?? '#999'; $advice = array( 'title' => $issue['msg'], 'subtitle' => $sev_label, 'color' => $cat_color, 'why' => '', 'situation' => '', 'example' => '', 'checklist' => array(), 'tip' => '' ); // WICHTIG: Erkennung läuft über den stabilen 'key' aus calculate_ai_analysis(), // NICHT mehr über strpos() auf den (übersetzten) 'msg'-Text — sonst würde die // Zuordnung bei jeder anderen Sprache als Deutsch brechen. if ( in_array( $issue_key, array( 'content_too_short', 'content_slightly_short' ), true ) ) { $missing = max( 0, 700 - $word_count ); $advice['why'] = __( 'Google bewertet Inhalte nach Tiefe und Nützlichkeit. Kurze Artikel werden oft als oberflächlich eingestuft.', $D ); $advice['situation'] = sprintf( __( 'Dein Artikel „%1$s" hat ca. %2$d Wörter. Mindestens 700 Wörter werden empfohlen.', $D ), $title_short, $word_count ); $advice['example'] = __( 'Beantworte 3–5 konkrete Fragen: Was ist das genau? Für wen? Was beachten? Typische Fehler? Alternativen?', $D ); $advice['checklist'] = array( __( 'Welche 3 Fragen hat der Leser nach dem Lesen noch?', $D ), __( 'Beantworte jede in einem eigenen H2-Abschnitt', $D ), __( 'Füge Beispiele, Zahlen oder Erfahrungen hinzu', $D ), sprintf( __( 'Ziel: mindestens %d Wörter', $D ), $word_count + $missing ) ); $advice['tip'] = __( 'Schreib nicht länger um des Schreibens willen. Jeder Absatz sollte echten Mehrwert bringen.', $D ); } elseif ( 'no_h2' === $issue_key ) { preg_match_all( '/]*>(.*?)<\/h2>/si', $post->post_content, $h2_m ); $existing_h2 = array_map( 'strip_tags', $h2_m[1] ?? array() ); $advice['why'] = __( 'H2-Überschriften sind das Inhaltsverzeichnis deines Artikels – für Google und den Leser.', $D ); $advice['situation'] = sprintf( __( 'In „%1$s" %2$s Empfohlen: 3–5.', $D ), $title_short, empty( $existing_h2 ) ? __( 'gibt es noch keine H2-Überschriften.', $D ) : sprintf( __( 'gibt es erst %d H2-Überschrift(en).', $D ), count( $existing_h2 ) ) ); $advice['example'] = "→ " . __( 'Was ist [Thema] – und warum ist es wichtig?', $D ) . "\n→ " . __( 'So funktioniert [Thema] in der Praxis', $D ) . "\n→ " . __( 'Die häufigsten Fehler', $D ) . "\n→ " . __( 'Häufige Fragen (FAQ)', $D ); $advice['checklist'] = array( __( '3–5 logische Hauptabschnitte aufteilen', $D ), __( 'Jede H2 behandelt eine eigene Frage', $D ), __( 'H2 als Nutzerversprechen formulieren', $D ), __( 'In langen Abschnitten H3 nutzen', $D ) ); $advice['tip'] = __( 'Baue das Hauptkeyword natürlich in mindestens eine H2 ein.', $D ); } elseif ( 'no_job_title' === $issue_key ) { $advice['why'] = __( 'Google bewertet E-E-A-T besonders stark. Eine fehlende Berufsbezeichnung signalisiert: „Wir wissen nicht wer das ist."', $D ); $advice['situation'] = sprintf( __( 'Der Autor von „%s" hat noch keine Berufsbezeichnung hinterlegt.', $D ), $title_short ); $advice['example'] = "→ „" . __( 'Staatlich geprüfte Hebamme', $D ) . "“"; $advice['checklist'] = array( __( 'Benutzer → Profil → Experten-Profil öffnen', $D ), __( 'Genaue Berufsbezeichnung eintragen', $D ), __( 'Optional: Zertifizierungen ergänzen', $D ), __( 'Speichern', $D ) ); $advice['tip'] = __( 'Je konsistenter die Bezeichnung mit deinen Social-Media-Profilen, desto stärker das E-E-A-T-Signal.', $D ); } elseif ( 'no_sameas' === $issue_key ) { $advice['why'] = __( 'SameAs-Links verbinden dein WordPress-Profil mit externen Autoritätsquellen.', $D ); $advice['situation'] = sprintf( __( 'Für den Autor von „%s" sind noch keine externen Profillinks hinterlegt.', $D ), $title_short ); $advice['example'] = "1. " . __( 'LinkedIn-Profil', $D ) . "\n2. Instagram\n3. XING\n4. " . __( 'Eigene Website', $D ); $advice['checklist'] = array( __( 'Benutzer → Profil → SameAs-Links eintragen', $D ), __( 'Eine URL pro Zeile', $D ), __( 'LinkedIn + Instagram + Website = maximaler Score-Bonus', $D ), __( 'Name auf allen Profilen identisch', $D ) ); $advice['tip'] = __( 'Konsistenz ist entscheidend: Name, Berufsbezeichnung und Profilbild sollten überall übereinstimmen.', $D ); } elseif ( 'no_internal_links' === $issue_key ) { // Geteilte Logik mit dem IMMER sichtbaren Vorschlags-Button (siehe // build_internal_links_advice) — Titel/Farbe kommen hier vom Issue selbst. $advice = $this->build_internal_links_advice( $post, $issue['msg'], $sev_label, $cat_color ); } elseif ( 'no_featured_image' === $issue_key ) { $advice['why'] = __( 'Ohne Featured Image: kein Thumbnail in Google Discover, kein Preview beim Teilen.', $D ); $advice['situation'] = sprintf( __( '„%s" hat noch kein Beitragsbild.', $D ), $title_short ); $advice['example'] = "• " . __( 'Mindestgröße: 1200 × 630 Pixel', $D ) . "\n• " . __( 'Aussagekräftig: Bild erklärt worum es geht', $D ) . "\n• " . __( 'Authentisch statt Stock-Foto-Klischee', $D ) . "\n• " . __( 'Alt-Text nicht vergessen', $D ); $advice['checklist'] = array( __( 'Beitrag → Beitragsbild → hochladen', $D ), __( 'Mindestens 1200 × 630 Pixel', $D ), __( 'Sprechenden Dateinamen verwenden', $D ), __( 'Alt-Text beim Hochladen ausfüllen', $D ) ); $advice['tip'] = __( 'Unsplash.com und Pexels.com bieten kostenlose, hochwertige Fotos.', $D ); } elseif ( in_array( $issue_key, array( 'meta_desc_length_suboptimal', 'meta_desc_too_short', 'no_meta_desc' ), true ) ) { $advice['why'] = __( 'Die Meta-Description entscheidet ob jemand in den Suchergebnissen klickt.', $D ); $advice['situation'] = sprintf( __( 'Für „%s" ist noch keine optimale Meta-Description hinterlegt.', $D ), $title_short ); $advice['example'] = '„[' . __( 'Konkrete Zahl', $D ) . '] + [' . __( 'Hauptnutzen', $D ) . '] + [' . __( 'Handlungsaufforderung', $D ) . ']" – 120–160 ' . __( 'Zeichen', $D ) . '.'; $advice['checklist'] = array( __( 'Meta-Generator nutzen (Button oben in der Sidebar)', $D ), __( 'Zwischen 120 und 160 Zeichen', $D ), __( 'Hauptkeyword in den ersten 70 Zeichen', $D ), __( 'Konkreten Nutzen formulieren', $D ) ); $advice['tip'] = __( 'Schreib die Meta wie eine Mini-Werbeanzeige: Was bekommt der Leser wenn er klickt?', $D ); } elseif ( 'stale_content' === $issue_key ) { $months_ago = round( ( time() - strtotime( $post->post_modified ) ) / MONTH_IN_SECONDS ); $advice['why'] = __( 'Google bevorzugt aktuellen Content. Artikel die seit über einem Jahr nicht angefasst wurden verlieren schrittweise an Sichtbarkeit.', $D ); $advice['situation'] = sprintf( __( '„%1$s" wurde zuletzt vor ca. %2$d Monaten aktualisiert.', $D ), $title_short, $months_ago ); $advice['example'] = "• " . __( 'Veraltete Zahlen aktualisieren', $D ) . "\n• " . __( 'Neue Erkenntnisse einarbeiten', $D ) . "\n• " . __( 'Veraltete Links ersetzen', $D ) . "\n• „" . __( 'Zuletzt aktualisiert', $D ) . "“-" . __( 'Datum ergänzen', $D ); $advice['checklist'] = array( __( 'Artikel durchlesen: veraltete Infos?', $D ), __( 'Mindestens 1–2 Absätze aktualisieren', $D ), __( 'Aktualisierungsdatum ergänzen', $D ), __( 'Speichern', $D ) ); $advice['tip'] = __( 'Google erkennt Änderungen ab ca. 10% des Inhalts – manchmal reicht ein neuer Abschnitt.', $D ); } else { $advice['why'] = __( 'Dieser Faktor beeinflusst die Sichtbarkeit und KI-Bewertung deines Inhalts.', $D ); $advice['situation'] = $issue['msg']; $advice['example'] = $issue['action'] ?? ''; $advice['checklist'] = array( $issue['action'] ?? __( 'Prüfe die Details und nimm die Änderung vor.', $D ) ); $advice['tip'] = __( 'Kleine Verbesserungen summieren sich. Jede behobene Issue erhöht deinen Score.', $D ); } return $advice; } }