'post', 'post_status' => 'publish', 'posts_per_page' => $limit, 'orderby' => 'date', 'order' => 'ASC', // oldest first ] ); $results = []; foreach ( $posts as $post ) { $score = self::decay_score( $post ); if ( $score['risk'] !== 'low' ) { $results[] = array_merge( [ 'post' => $post ], $score ); } } usort( $results, fn( $a, $b ) => $b['score'] <=> $a['score'] ); return $results; } public static function decay_score( $post ) { $score = 0; $reasons = []; $now = time(); $created = strtotime( $post->post_date ); $modified = strtotime( $post->post_modified ); $age_days = ( $now - $created ) / DAY_IN_SECONDS; $modified_days = ( $now - $modified ) / DAY_IN_SECONDS; // Post is older than 12 months. if ( $age_days > 365 ) { $score += 20; /* translators: %.1f = number of years (e.g. 2.3) */ $reasons[] = sprintf( __( 'Post is over 1 year old (%.1f years).', 'adworksbid-seo' ), round( $age_days / 365, 1 ) ); } // Not updated in 6+ months. if ( $modified_days > 180 ) { $score += 25; /* translators: %d = number of months */ $reasons[] = sprintf( __( 'Not updated in %d months.', 'adworksbid-seo' ), round( $modified_days / 30 ) ); } elseif ( $modified_days > 90 ) { $score += 10; /* translators: %d = number of months */ $reasons[] = sprintf( __( 'Not updated in %d months.', 'adworksbid-seo' ), round( $modified_days / 30 ) ); } // Time-sensitive keywords in title. $time_keywords = [ 'best', 'top', 'review', 'vs', 'versus', 'comparison', 'guide', 'how to', 'tutorial', 'checklist', 'list', 'tips', 'tricks', 'tools', 'plugins', 'apps' ]; $title_lower = mb_strtolower( $post->post_title ); foreach ( $time_keywords as $kw ) { if ( false !== mb_strpos( $title_lower, $kw ) ) { $score += 10; /* translators: %s = the time-sensitive keyword found in the title */ $reasons[] = sprintf( __( "Title contains '%s' - time-sensitive topic.", 'adworksbid-seo' ), $kw ); break; } } // Year in title (e.g., "Best Plugins 2022"). $title_year = self::old_year_in( $post->post_title ); if ( $title_year ) { $score += 30; /* translators: %s = the year found in the title (e.g. 2022) */ $reasons[] = sprintf( __( 'Title contains year %s - almost certainly outdated.', 'adworksbid-seo' ), $title_year ); } // Short content = thin/low-effort = higher decay risk. $word_count = str_word_count( wp_strip_all_tags( $post->post_content ) ); if ( $word_count < 500 ) { $score += 15; /* translators: %d = word count of the post */ $reasons[] = sprintf( __( 'Content is only %d words - thin posts decay faster.', 'adworksbid-seo' ), $word_count ); } // No focus keyword = not optimised = higher risk. if ( ! AWBSEO_Meta_Manager::get( $post->ID, 'focus_keyword' ) ) { $score += 5; $reasons[] = __( 'No focus keyword set.', 'adworksbid-seo' ); } $risk = 'low'; if ( $score >= 50 ) $risk = 'high'; elseif ( $score >= 25 ) $risk = 'medium'; return [ 'score' => $score, 'risk' => $risk, 'reasons' => $reasons, 'age_days' => round( $age_days ), 'modified_days' => round( $modified_days ), 'word_count' => $word_count, ]; } /** * First year in $text that is before the current year (2000 or later), * or '' if none. Replaces a hardcoded 2010-2023 range that stopped * catching titles like "Best Tools 2025" once the calendar moved on. */ public static function old_year_in( $text ) { $current = (int) date_i18n( 'Y' ); if ( preg_match_all( '/\b(20\d\d)\b/', (string) $text, $m ) ) { foreach ( $m[1] as $y ) { if ( (int) $y >= 2005 && (int) $y < $current ) { return $y; } } } return ''; } /** Short snippet of $text around byte offset $pos, for showing where something was found. */ private static function snippet( $text, $pos, $len ) { $start = max( 0, $pos - 40 ); $chunk = substr( $text, $start, $len + 80 ); $chunk = wp_check_invalid_utf8( $chunk, true ); return trim( ( $start > 0 ? '…' : '' ) . preg_replace( '/\s+/', ' ', $chunk ) . '…' ); } /** * Refresh checklist for a post. Every item is worked out for this * specific post: 'done' => false shows a gray check (still needs doing, * listed first), 'done' => true a green check (already fine). 'details' * says exactly what was found - the outdated years/prices in the text, * the broken URLs, the word count gap, related posts to link to - so * the box tells you what to change, not just what kind of thing to check. * * @return array[] Each item: [ 'text' => string, 'done' => bool, 'details' => array[] ] * where each detail is [ 'text' => string, 'url' => string (optional) ]. */ public static function refresh_suggestions( $post_id ) { global $wpdb; $post = get_post( $post_id ); $score = self::decay_score( $post ); $recent = $score['modified_days'] <= 90; $content = (string) $post->post_content; $plain = trim( preg_replace( '/\s+/', ' ', wp_strip_all_tags( strip_shortcodes( $content ) ) ) ); $current = date_i18n( 'Y' ); $months = max( 1, (int) round( $score['modified_days'] / 30 ) ); $items = []; // ── 1. Outdated facts: old years, prices, percentages in the text ── $facts = []; if ( preg_match_all( '/\b(20\d\d)\b/', $plain, $ym, PREG_OFFSET_CAPTURE ) ) { foreach ( $ym[1] as $hit ) { if ( (int) $hit[0] >= 2005 && (int) $hit[0] < (int) $current && count( $facts ) < 3 ) { $facts[ $hit[1] ] = [ 'text' => sprintf( __( 'Mentions %1$s: "%2$s"', 'adworksbid-seo' ), $hit[0], self::snippet( $plain, $hit[1], 4 ) ) ]; } } } if ( count( $facts ) < 3 && preg_match_all( '/(?:[$€£]\s?\d[\d,.]*|\b\d[\d,.]*\s?(?:USD|EUR|AED|SAR|LBP|KWD|QAR|%))/u', $plain, $pm, PREG_OFFSET_CAPTURE ) ) { foreach ( $pm[0] as $hit ) { if ( count( $facts ) >= 3 ) break; $near = false; foreach ( array_keys( $facts ) as $off ) { if ( abs( $off - $hit[1] ) < 80 ) { $near = true; break; } } if ( ! $near ) { $facts[ $hit[1] ] = [ 'text' => sprintf( __( 'Figure %1$s: "%2$s"', 'adworksbid-seo' ), $hit[0], self::snippet( $plain, $hit[1], strlen( $hit[0] ) ) ) ]; } } } ksort( $facts ); $items[] = [ 'text' => $facts ? __( 'Double-check these dates and figures - they may be out of date:', 'adworksbid-seo' ) : __( 'Review and update all statistics, prices, and data.', 'adworksbid-seo' ), 'done' => $recent && ! $facts, 'details' => $facts ? array_values( $facts ) : ( $recent ? [] : [ [ 'text' => sprintf( _n( 'No dated numbers detected, but the post hasn\'t been touched in %d month - skim it for anything that has changed.', 'No dated numbers detected, but the post hasn\'t been touched in %d months - skim it for anything that has changed.', $months, 'adworksbid-seo' ), $months ) ] ] ), ]; // ── 2. Broken outbound links: list the actual URLs ── $broken = []; $table = $wpdb->prefix . 'awb_broken_links'; $has_table = class_exists( 'AWBSEO_Broken_Links' ) && $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table; $checked = false; if ( $has_table ) { $broken_sql = AWBSEO_Broken_Links::broken_sql( 'status_code' ); $broken = $wpdb->get_results( $wpdb->prepare( "SELECT url, status_code FROM {$table} WHERE post_id = %d AND {$broken_sql} AND fix_status != 'fixed' ORDER BY id LIMIT 5", $post_id ) ); $checked = (bool) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE post_id = %d", $post_id ) ); } $broken_details = []; foreach ( (array) $broken as $b ) { $broken_details[] = [ 'text' => sprintf( /* translators: 1: URL, 2: problem description */ __( '%1$s (%2$s)', 'adworksbid-seo' ), $b->url, 404 === (int) $b->status_code ? __( 'page not found', 'adworksbid-seo' ) : __( 'no response', 'adworksbid-seo' ) ), 'url' => $b->url, ]; } if ( ! $broken && ! $checked ) { $broken_details[] = [ 'text' => __( 'This post\'s links haven\'t been checked yet. Run SEO Analysis on the Dashboard to check them.', 'adworksbid-seo' ) ]; } $items[] = [ 'text' => $broken /* translators: %d = number of broken links in this post */ ? sprintf( _n( 'Fix or remove %d broken link:', 'Fix or remove %d broken links:', count( $broken ), 'adworksbid-seo' ), count( $broken ) ) : __( 'No broken links found in this post.', 'adworksbid-seo' ), 'done' => ! $broken && $checked, 'details' => $broken_details, ]; // ── 3. Outdated year in the title: show the suggested new title ── $title_year = self::old_year_in( $post->post_title ); $items[] = [ 'text' => $title_year /* translators: 1: old year, 2: current year */ ? sprintf( __( 'Change %1$s to %2$s in the title (only if you actually refresh the content):', 'adworksbid-seo' ), $title_year, $current ) : __( 'Title has no outdated year.', 'adworksbid-seo' ), 'done' => ! $title_year, 'details' => $title_year ? [ [ 'text' => '"' . preg_replace( '/\b' . preg_quote( $title_year, '/' ) . '\b/', $current, $post->post_title, 1 ) . '"' ] ] : [], ]; // ── 4. Internal links: count + suggest newer related posts not already linked ── preg_match_all( '/]+href=["\']' . preg_quote( home_url(), '/' ) . '[^"\']*["\'][^>]*>/i', $content, $int_links ); $int_count = count( $int_links[0] ); $related_details = []; if ( $int_count < 2 ) { $related_args = [ 'post_type' => $post->post_type, 'post_status' => 'publish', 'posts_per_page' => 8, 'post__not_in' => [ $post_id ], 'orderby' => 'date', 'order' => 'DESC', 'no_found_rows' => true, ]; $cats = 'post' === $post->post_type ? wp_get_post_categories( $post_id ) : []; if ( $cats ) { $related_args['category__in'] = $cats; } foreach ( get_posts( $related_args ) as $rel ) { if ( count( $related_details ) >= 3 ) break; $link = get_permalink( $rel ); if ( false === strpos( $content, untrailingslashit( $link ) ) ) { $related_details[] = [ 'text' => sprintf( __( 'Link to: %s', 'adworksbid-seo' ), get_the_title( $rel ) ), 'url' => $link ]; } } } $items[] = [ 'text' => $int_count >= 2 /* translators: %d = number of internal links */ ? sprintf( _n( 'Has %d internal link.', 'Has %d internal links.', $int_count, 'adworksbid-seo' ), $int_count ) /* translators: %d = number of internal links */ : sprintf( _n( 'Only %d internal link - add at least 2. Newer related posts you could link to:', 'Only %d internal links - add at least 2. Newer related posts you could link to:', $int_count, 'adworksbid-seo' ), $int_count ), 'done' => $int_count >= 2, 'details' => $related_details, ]; // ── 5. Content length: exact gap ── $wc = (int) $score['word_count']; $items[] = [ 'text' => $wc >= 500 /* translators: %s = word count */ ? sprintf( __( '%s words - long enough.', 'adworksbid-seo' ), number_format_i18n( $wc ) ) /* translators: 1: current word count, 2: words to add */ : sprintf( __( 'Only %1$s words - add about %2$s more (aim for 1,000+) with new examples, FAQs, or updated information.', 'adworksbid-seo' ), number_format_i18n( $wc ), number_format_i18n( 500 - $wc ) ), 'done' => $wc >= 500, 'details' => [], ]; // ── 6. Meta description: show what's there now ── $desc = trim( (string) AWBSEO_Meta_Manager::get( $post_id, 'description' ) ); $desc_len = mb_strlen( $desc ); $desc_ok = $desc && $desc_len >= 120 && $desc_len <= 160; if ( ! $desc ) { $desc_text = __( 'No meta description set - write one (120-160 characters).', 'adworksbid-seo' ); } elseif ( ! $desc_ok ) { /* translators: %d = number of characters */ $desc_text = sprintf( __( 'Meta description is %d characters - aim for 120-160:', 'adworksbid-seo' ), $desc_len ); } elseif ( ! $recent ) { $desc_text = __( 'Rewrite the meta description to match the refreshed content. Current one:', 'adworksbid-seo' ); } else { $desc_text = __( 'Meta description looks good.', 'adworksbid-seo' ); } $items[] = [ 'text' => $desc_text, 'done' => $desc_ok && $recent, 'details' => ( $desc && ! ( $desc_ok && $recent ) ) ? [ [ 'text' => '"' . $desc . '"' ] ] : [], ]; // ── 7. Target keyword ── $kw = trim( (string) AWBSEO_Meta_Manager::get( $post_id, 'focus_keyword' ) ); $items[] = [ 'text' => $kw /* translators: %s = focus keyword */ ? sprintf( __( 'Target keyword set: "%s".', 'adworksbid-seo' ), $kw ) : __( 'No target keyword set - pick the search phrase this post should rank for.', 'adworksbid-seo' ), 'done' => '' !== $kw, 'details' => [], ]; // ── 8. Freshness date ── $items[] = [ 'text' => $recent ? __( 'Updated recently - the dateModified in its schema is current.', 'adworksbid-seo' ) /* translators: %d = number of days since last update */ : sprintf( __( 'Last updated %d days ago. Save the post after editing so Google sees a fresh dateModified (CitedSEO updates it automatically).', 'adworksbid-seo' ), (int) $score['modified_days'] ), 'done' => $recent, 'details' => [], ]; return array_merge( array_values( array_filter( $items, fn( $i ) => ! $i['done'] ) ), array_values( array_filter( $items, fn( $i ) => $i['done'] ) ) ); } }