post_content; $text = wp_strip_all_tags( $content ); $word_count = str_word_count( $text ); $issues = array(); 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;
}
}