'; // 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;
}
}