$label ) { if ( $key === 'date' ) { $new['awbseo_score'] = ' SEO'; } $new[ $key ] = $label; } return $new; } public static function maybe_add_cpt_column( $post_type, $post_type_object ) { if ( $post_type_object->show_ui && ! in_array( $post_type, [ 'post', 'page' ], true ) ) { add_filter( "manage_{$post_type}_posts_columns", [ __CLASS__, 'add_column' ] ); // manage_posts_custom_column (registered in init()) fires for all post types // including custom ones - no need to re-register render_column here, which // would cause it to run twice (double badge) on CPT list tables. add_filter( "manage_edit-{$post_type}_sortable_columns", [ __CLASS__, 'sortable_columns' ] ); } } /* ── Column renderer ───────────────────────────────────────────────────── */ public static function render_column( $column, $post_id ) { if ( $column !== 'awbseo_score' ) { return; } $keyword = AWBSEO_Meta_Manager::get( $post_id, 'focus_keyword' ); $has_title = (bool) AWBSEO_Meta_Manager::get( $post_id, 'title' ); $has_desc = (bool) AWBSEO_Meta_Manager::get( $post_id, 'description' ); // Read cached score - fast, no analysis on list view. $score = get_post_meta( $post_id, self::META_SCORE, true ); $issues = get_post_meta( $post_id, self::META_ISSUES, true ); $analyzed = $score !== ''; // empty string = never saved, not the same as 0 if ( ! $analyzed ) { // Not yet cached - show a prompt to save/analyze. echo '
-
' . esc_html( $keyword ?: 'No keyword' ) . ' Save to score
'; return; } $score = (int) $score; $issues = (int) $issues; // Colour tier. if ( ! $keyword ) { $tier_class = 'awb-score-grey'; $tier_label = 'No keyword'; } elseif ( $score >= 70 ) { $tier_class = 'awb-score-green'; $tier_label = 'Good'; } elseif ( $score >= 40 ) { $tier_class = 'awb-score-orange'; $tier_label = 'Improve'; } else { $tier_class = 'awb-score-red'; $tier_label = 'Poor'; } $edit_url = get_edit_post_link( $post_id ); echo '
'; // Score circle. echo ''; echo esc_html( $keyword ? $score : '-' ); echo ''; // Meta info block. echo '
'; // Focus keyword. if ( $keyword ) { echo '' . esc_html( mb_strlen( $keyword ) > 22 ? mb_substr( $keyword, 0, 22 ) . '…' : $keyword ) . ''; } else { echo 'No keyword'; } // Mini status pills. echo ''; echo self::pill( 'T', $has_title, 'Custom SEO title' . ( $has_title ? ' set' : ' missing' ) ); echo self::pill( 'D', $has_desc, 'Meta description' . ( $has_desc ? ' set' : ' missing' ) ); echo self::pill( 'K', (bool) $keyword, 'Focus keyword' . ( $keyword ? ' set' : ' missing' ) ); if ( $issues > 0 ) { echo '' . (int) $issues . ' ⚠'; } echo ''; // pills echo '
'; // meta echo '
'; // wrap } private static function pill( $label, $ok, $title ) { $class = $ok ? 'awb-pill awb-pill-ok' : 'awb-pill awb-pill-miss'; return '' . esc_html( $label ) . ''; } /* ── Score caching ─────────────────────────────────────────────────────── */ public static function cache_score( $post_id, $post ) { // Skip autosaves, revisions, and non-public post types. if ( ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) || wp_is_post_revision( $post_id ) || ! in_array( $post->post_status, [ 'publish', 'draft', 'pending', 'future' ], true ) ) { return; } self::analyze_and_cache( $post_id ); // Recheck this post's own outbound links right away, so a broken // link that's just been fixed - removed, replaced, or the target // came back - clears off the Dashboard's broken-link count the // moment you hit Update, instead of waiting on the next scheduled // or manually triggered site-wide scan. See // AWBSEO_Broken_Links::scan_post() and prune_stale_records(). if ( function_exists( 'set_time_limit' ) ) { @set_time_limit( 60 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged } if ( class_exists( 'AWBSEO_Broken_Links' ) ) { AWBSEO_Broken_Links::scan_post( $post_id ); } } /** * Runs the on-page analyzer for one post and caches score + issue count. * Shared by save-post caching, the Posts/Pages bulk action, the * Dashboard's "Run SEO Analysis" button, and the Bulk Editor (which * updates SEO meta directly via AJAX, bypassing save_post entirely) - * so a post's score and issue count stay in sync no matter which of * those touched it. * * @param int $post_id * @return int The calculated score (0-100). */ public static function analyze_and_cache( $post_id ) { $result = AWBSEO_On_Page_Analyzer::analyze( $post_id ); $score = (int) ( $result['score'] ?? 0 ); $failed = array_values( array_filter( $result['checks'] ?? [], fn( $c ) => in_array( $c['status'], [ 'warn', 'fail' ], true ) ) ); $issues = count( $failed ); update_post_meta( $post_id, self::META_SCORE, $score ); update_post_meta( $post_id, self::META_ISSUES, $issues ); update_post_meta( $post_id, self::META_FAILED, implode( ',', array_map( fn( $c ) => sanitize_key( $c['id'] ), $failed ) ) ); return $score; } /* ── Sortable columns ──────────────────────────────────────────────────── */ public static function sortable_columns( $cols ) { $cols['awbseo_score'] = 'awbseo_score'; return $cols; } public static function sort_by_score( $query ) { if ( ! is_admin() || ! $query->is_main_query() ) { return; } if ( $query->get( 'orderby' ) === 'awbseo_score' ) { $query->set( 'meta_key', self::META_SCORE ); $query->set( 'orderby', 'meta_value_num' ); } } /* ── Bulk action: Calculate SEO Scores ────────────────────────────────── */ public static function register_bulk_action( $actions ) { $actions['awbseo_calc_scores'] = __( '⚡ Calculate SEO Scores', 'awb-seo' ); return $actions; } /** * Process the bulk action. * * WordPress calls this filter after verifying the nonce and iterating $post_ids. * We run the analyzer on each selected post and redirect back with a count notice. * * @param string $redirect_url Current redirect URL (with existing query args). * @param string $action The bulk action key. * @param int[] $post_ids Selected post IDs. * @return string Updated redirect URL. */ public static function handle_bulk_action( $redirect_url, $action, $post_ids ) { if ( $action !== 'awbseo_calc_scores' ) { return $redirect_url; } $processed = 0; foreach ( $post_ids as $post_id ) { $post = get_post( $post_id ); if ( ! $post ) { continue; } // Reuse the same analysis path used on save_post. self::analyze_and_cache( $post_id ); $processed++; } // Pass count back through a query arg so the notice can display it. return add_query_arg( 'awbseo_scored', $processed, $redirect_url ); } /** Admin notice shown after the bulk action redirects back. */ public static function bulk_action_notice() { if ( ! isset( $_REQUEST['awbseo_scored'] ) ) { return; } $count = (int) $_REQUEST['awbseo_scored']; printf( '

%s

', sprintf( /* translators: %d = number of posts scored */ esc_html( _n( 'SEO score calculated for %d post.', 'SEO scores calculated for %d posts.', $count, 'awb-seo' ) ), $count ) ); } /* ── "Run SEO Analysis" (Dashboard button, runs in the background) ───────── */ // // Clicking the button only resolves the list of posts to cover and hands // it to a background job - a self-chaining WP-Cron step (see // process_cron_step()) that keeps processing a few posts at a time until // the whole list is done, on its own, whether or not the Dashboard tab // is still open. The browser calls ajax_seo_analysis_status() every // couple of seconds purely to show a progress bar while it's open; that // polling stopping (tab closed, navigated away) has no effect on the // scan itself. heartbeat_received() below is what notices the scan has // finished from any other wp-admin page and pops the "done" notification. /** * Step 1: resolve which posts this run will cover and queue them for * the background job. Returns immediately with the total count so the * browser can show a progress bar right away. */ public static function ajax_seo_analysis_start() { check_ajax_referer( 'awbseo_admin', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => __( 'Forbidden', 'adworksbid-seo' ) ] ); } // Scan every published post and page. This used to be capped at the // 300 most recently modified, so on larger sites the rest were never // scored no matter how many times the button was clicked. The scan // already runs in small background batches (process_cron_step()), // so there's no timeout risk in covering everything. The filter // stays for anyone who wants to cap it on a very large site. $limit = (int) apply_filters( 'awbseo_seo_analysis_limit', -1 ); $base_args = [ 'post_type' => [ 'post', 'page' ], 'post_status' => 'publish', 'posts_per_page' => -1, 'orderby' => 'modified', 'order' => 'DESC', 'fields' => 'ids', 'no_found_rows' => true, ]; // Never-scored pages go first, so even a scan that gets interrupted // fills in the "not analyzed yet" gap before re-scoring the rest. $unscored = array_map( 'intval', get_posts( array_merge( $base_args, [ 'meta_query' => [ [ 'key' => self::META_SCORE, 'compare' => 'NOT EXISTS' ] ], ] ) ) ); $all = array_map( 'intval', get_posts( $base_args ) ); $ids = array_values( array_unique( array_merge( $unscored, array_diff( $all, $unscored ) ) ) ); if ( $limit > 0 ) { $ids = array_slice( $ids, 0, $limit ); } set_transient( self::QUEUE_KEY, $ids, self::TRANSIENT_TTL ); set_transient( self::STATUS_KEY, [ 'status' => empty( $ids ) ? 'done' : 'running', 'total' => count( $ids ), 'done' => 0, 'started' => time(), 'finished' => empty( $ids ) ? time() : null, 'notified' => empty( $ids ), // nothing to notify about if there was nothing to scan ], self::TRANSIENT_TTL ); if ( ! empty( $ids ) ) { if ( ! wp_next_scheduled( self::CRON_HOOK ) ) { wp_schedule_single_event( time(), self::CRON_HOOK ); } // Kick the first step off right away instead of waiting for // WP-Cron's normal "next time someone visits the site" trigger. if ( function_exists( 'spawn_cron' ) ) { spawn_cron(); } } wp_send_json_success( [ 'total' => count( $ids ) ] ); } /** * Read-only poll the browser calls every couple of seconds while the * Dashboard tab happens to be open, purely to draw the progress bar. * The scan itself doesn't depend on this ever being called. */ public static function ajax_seo_analysis_status() { check_ajax_referer( 'awbseo_admin', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => __( 'Forbidden', 'adworksbid-seo' ) ] ); } $status = get_transient( self::STATUS_KEY ); wp_send_json_success( $status ?: [ 'status' => 'idle' ] ); } /** * Processes one small batch of the queued post IDs (score + broken-link * check for each) and, if posts remain, reschedules itself right away - * a self-chaining background job. Called by WP-Cron, and also nudged * directly from heartbeat_received() as a backstop on quiet sites. */ public static function process_cron_step() { $queue = get_transient( self::QUEUE_KEY ); if ( ! is_array( $queue ) || empty( $queue ) ) { self::finish_analysis(); return; } // A batch this small never needs long, but outbound link checks are // network calls - leave some headroom on slower sites. if ( function_exists( 'set_time_limit' ) ) { @set_time_limit( 60 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged } $batch = array_splice( $queue, 0, self::BATCH_SIZE ); foreach ( $batch as $post_id ) { if ( $post_id && get_post( $post_id ) ) { self::analyze_and_cache( $post_id ); if ( class_exists( 'AWBSEO_Broken_Links' ) ) { AWBSEO_Broken_Links::scan_post( $post_id ); } } } set_transient( self::QUEUE_KEY, $queue, self::TRANSIENT_TTL ); $status = get_transient( self::STATUS_KEY ); if ( ! is_array( $status ) ) { $status = [ 'status' => 'running', 'total' => 0, 'done' => 0, 'started' => time(), 'notified' => false ]; } $status['done'] = (int) $status['done'] + count( $batch ); if ( empty( $queue ) ) { $status['status'] = 'done'; $status['finished'] = time(); set_transient( self::STATUS_KEY, $status, self::TRANSIENT_TTL ); } else { set_transient( self::STATUS_KEY, $status, self::TRANSIENT_TTL ); wp_schedule_single_event( time(), self::CRON_HOOK ); if ( function_exists( 'spawn_cron' ) ) { spawn_cron(); } } } private static function finish_analysis() { $status = get_transient( self::STATUS_KEY ); if ( ! is_array( $status ) ) { $status = [ 'total' => 0, 'done' => 0, 'started' => time(), 'notified' => false ]; } $status['status'] = 'done'; $status['finished'] = time(); set_transient( self::STATUS_KEY, $status, self::TRANSIENT_TTL ); } /** * Heartbeat ticks on every wp-admin screen by default (once we enqueue * it site-wide - see AWBSEO_Admin::enqueue_analysis_notifier()), which * makes it the one thing that reliably runs no matter which page the * admin has navigated to since starting a scan. Two jobs here: nudge * the background job forward a step if it's still running (a backstop * for quiet sites with no other traffic to trigger WP-Cron in between * steps), and hand back a one-time "done" flag for the browser to turn * into a notification. */ public static function heartbeat_received( $response, $data ) { if ( empty( $data['awbseo_check_analysis'] ) || ! current_user_can( 'manage_options' ) ) { return $response; } $status = get_transient( self::STATUS_KEY ); if ( is_array( $status ) && 'running' === $status['status'] ) { self::process_cron_step(); $status = get_transient( self::STATUS_KEY ); } if ( is_array( $status ) && 'done' === $status['status'] && empty( $status['notified'] ) ) { $response['awbseo_analysis'] = $status; $status['notified'] = true; set_transient( self::STATUS_KEY, $status, self::TRANSIENT_TTL ); } return $response; } /** * While a scan is running, speed up Heartbeat site-wide (default is a * lot slower) so the background job gets nudged along more often and * the "done" notification shows up sooner instead of a minute-plus * after the fact. */ public static function maybe_speed_up_heartbeat( $settings ) { $status = get_transient( self::STATUS_KEY ); if ( is_array( $status ) && 'running' === $status['status'] ) { $settings['interval'] = 15; } return $settings; } }