add_action( 'abds_seo_run_site_scan', $this, 'run_site_scan' ); $loader->add_action( 'abds_seo_scan_batch', $this, 'scan_batch', 10, 3 ); } /** * Entry point for the recurring scheduled scan (and for a manually * triggered "Rescan site" action from the admin). Creates a scan record * and schedules one throttled Action Scheduler batch per BATCH_SIZE * posts. */ public function run_site_scan(): void { if ( ! function_exists( 'as_schedule_single_action' ) ) { return; // Action Scheduler not available; caller falls back to no-op rather than blocking a real request. } global $wpdb; $post_ids = get_posts( array( 'post_type' => 'any', 'post_status' => 'publish', 'posts_per_page' => -1, // phpcs:ignore WordPress.WP.PostsPerPage.posts_per_page_posts_per_page -- internal background batch: collects every published post's ID once, then queues a separate per-post scheduled action below; no cap on how many pages are audited. 'fields' => 'ids', ) ); if ( empty( $post_ids ) ) { return; } $scans_table = $wpdb->prefix . 'abds_seo_scans'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $scans_table is built only from $wpdb->prefix plus a hardcoded literal, never user input; $wpdb->insert() safely escapes its values via the format array. Runs once per scheduled/manually-triggered scan, not a cacheable read path. $wpdb->insert( $scans_table, array( 'started_at' => current_time( 'mysql' ), 'status' => 'running', 'pages_scanned' => 0, ), array( '%s', '%s', '%d' ) ); $scan_id = (int) $wpdb->insert_id; $pages_total = count( $post_ids ); $batches = array_chunk( $post_ids, self::BATCH_SIZE ); foreach ( $batches as $index => $batch_ids ) { as_schedule_single_action( time() + ( $index * self::BATCH_THROTTLE_SECONDS ), 'abds_seo_scan_batch', array( $batch_ids, $scan_id, $pages_total ), self::GROUP ); } } /** * Scans one batch of posts and updates the scan's running total. Marks * the scan as completed once the cumulative pages_scanned count reaches * $pages_total. * * $pages_total is passed through from run_site_scan() rather than * re-derived from Action Scheduler's own queue state (e.g. * as_has_scheduled_action()): during a batch's own execution, Action * Scheduler still reports that action itself as in-progress, so a * "any batches still pending/running?" check can never reliably tell * the last batch apart from an earlier one — comparing against a * known total sidesteps that entirely. * * @param int[] $post_ids Post IDs in this batch. * @param int $scan_id wp_abds_seo_scans row ID this batch belongs to. * @param int $pages_total Total pages across every batch of this scan. */ public function scan_batch( array $post_ids, int $scan_id, int $pages_total ): void { global $wpdb; $scanner = new ABDS_SEO_Site_Scanner(); $analyzer = new ABDS_SEO_Content_Analyzer(); foreach ( $post_ids as $post_id ) { $post_id = (int) $post_id; $scanner->scan_post( $post_id ); // Backfills/refreshes content_score for every post a full site // scan touches, not just ones someone happens to open in the // editor (see ABDS_SEO_Content_Score_Cache, which handles the // save_post case) — analyze() is a cheap no-op when no focus // keyword is set (see its own empty_result() early return), so // this doesn't meaningfully add to the batch's cost for posts // that haven't been given one yet. $keyword = (string) get_post_meta( $post_id, '_abds_seo_focus_keyword', true ); $analyzer->analyze( $post_id, $keyword ); } $scans_table = $wpdb->prefix . 'abds_seo_scans'; // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $scans_table is built only from $wpdb->prefix plus a hardcoded literal, never user input; values are passed through $wpdb->prepare(). Runs once per background batch, not on a cacheable read path. $wpdb->query( $wpdb->prepare( "UPDATE {$scans_table} SET pages_scanned = pages_scanned + %d WHERE id = %d", count( $post_ids ), $scan_id ) ); $pages_scanned = (int) $wpdb->get_var( $wpdb->prepare( "SELECT pages_scanned FROM {$scans_table} WHERE id = %d", $scan_id ) ); // phpcs:enable if ( $pages_scanned >= $pages_total ) { $this->finish_scan( $scan_id ); } } /** * Marks a scan as completed and records the site's average technical * score at that point. * * @param int $scan_id wp_abds_seo_scans row ID. */ protected function finish_scan( int $scan_id ): void { global $wpdb; $scans_table = $wpdb->prefix . 'abds_seo_scans'; $page_scores_table = $wpdb->prefix . 'abds_seo_page_scores'; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $page_scores_table is built only from $wpdb->prefix plus a hardcoded literal, never user input. Runs once per completed scan, not on a cacheable read path. $avg_score = $wpdb->get_var( "SELECT AVG(technical_score) FROM {$page_scores_table}" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $scans_table is built only from $wpdb->prefix plus a hardcoded literal, never user input; $wpdb->update() safely escapes its values via the format array. Runs once per completed scan, not a cacheable read path. $wpdb->update( $scans_table, array( 'finished_at' => current_time( 'mysql' ), 'status' => 'completed', 'avg_score' => $avg_score, ), array( 'id' => $scan_id ), array( '%s', '%s', '%f' ), array( '%d' ) ); } }