25, 'high' => 15, 'medium' => 10, 'low' => 5, 'notice' => 2, ); /** * Per-URL fetch timeout. Deliberately short (owner-mandated fix, * 28Aug26): a site with a closed/firewalled/basic-auth-protected * loopback doesn't just fail one page's schema check quietly — every * URL in the batch fails the same way, and at the pre-fix 30s timeout * a fully-blocked BATCH_SIZE=20 batch could approach 10 minutes, * risking the batch action itself dying against a host's own * max_execution_time before finishing every URL — the exact * "run stuck forever" failure this was supposed to prevent, just * happening in production instead of a dev stand. 5s keeps a * worst-case fully-blocked batch under ~100s, and — more importantly — * every URL still gets a real completed/failed status either way (see * run_check_for_url()), so a blocked fetch can never leave a row * hanging. */ const FETCH_TIMEOUT_SECONDS = 5; /** * Registered checks, in run order. * * @var ABDS_SEO_Audit_Check[] */ protected array $checks; /** * Constructor. * * @param ?ABDS_SEO_Audit_Check[] $checks Checks to run; defaults to the real registry (filterable). */ public function __construct( ?array $checks = null ) { $this->checks = $checks ?? self::default_checks(); } /** * The real check registry. Filterable so Pro (or a future release) can * add checks without touching this class — the registry, not the engine, * is the extension point. * * @return ABDS_SEO_Audit_Check[] */ protected static function default_checks(): array { /** * Filters the list of audit checks the engine runs for every URL. * * @since 0.12.0 * * @param ABDS_SEO_Audit_Check[] $checks Checks, in run order. */ return apply_filters( 'abds_seo_audit_checks', array( new ABDS_SEO_Audit_Check_Technical(), new ABDS_SEO_Audit_Check_Content(), new ABDS_SEO_Audit_Check_Schema(), ) ); } /** * Runs every registered check against one wp_abds_seo_audit_urls row, * writes its issues, and updates that row's own status/score/checked_at. * * @param int $audit_url_id Row ID in wp_abds_seo_audit_urls. */ public function run_check_for_url( int $audit_url_id ): void { global $wpdb; $urls_table = $wpdb->prefix . 'abds_seo_audit_urls'; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $urls_table is built only from $wpdb->prefix plus a hardcoded literal, never user input; $audit_url_id is passed through $wpdb->prepare(). Runs once per URL in a background batch. $audit_url = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$urls_table} WHERE id = %d", $audit_url_id ) ); if ( ! $audit_url ) { return; } $post = ( 'post' === $audit_url->object_type && $audit_url->object_id ) ? get_post( (int) $audit_url->object_id ) : null; if ( ! $post instanceof WP_Post ) { $this->update_url_row( $audit_url_id, 'failed', 0, 0, 0 ); return; } $fetch = $this->fetch_html( (string) $audit_url->url ); $issues = array(); // Owner-mandated fix (28Aug26): a failed fetch must be visible data, // not a silent gap that looks identical to "this page genuinely has // no schema markup." The checks still run below on whatever they can // (post meta/content, not the failed HTML) — this doesn't skip them, // it just makes the fetch failure itself a real, honest finding // instead of disappearing into "0 issues from the schema check". if ( 200 !== $fetch['http_status'] ) { $issues[] = array( 'issue_key' => 'fetch_failed', 'category' => 'technical', 'severity' => 'notice', 'message' => sprintf( /* translators: %d: HTTP status code, or 0 if the request never got a response at all. */ __( 'Could not fetch the page to check its rendered content (HTTP %d).', 'abds-seo' ), $fetch['http_status'] ), 'details' => array( 'http_status' => $fetch['http_status'] ), ); } foreach ( $this->checks as $check ) { $issues = array_merge( $issues, $check->run( $post, $fetch['html'] ) ); } $this->persist_issues( (int) $audit_url->run_id, $audit_url_id, $issues ); $score = $this->calculate_score( $issues ); $this->update_url_row( $audit_url_id, 'completed', $fetch['http_status'], $score, count( $issues ) ); } /** * Fetches a URL's rendered HTML for every check to share, same loopback- * rewrite filter hook Site_Scanner::fetch_rendered_html() already * documents (some containerized dev stacks need it to resolve * home_url() internally) — kept as its own small method rather than * exposing Site_Scanner's protected fetch helper, since this fetch also * needs to report the HTTP status code back, which Site_Scanner's * version deliberately doesn't (it only needs the body). * * @param string $url URL to fetch. * @return array{html:string, http_status:int} Empty html and status 0 on any failure. */ protected function fetch_html( string $url ): array { if ( '' === $url ) { return array( 'html' => '', 'http_status' => 0, ); } /** This filter is documented in ABDS_SEO_Site_Scanner::fetch_rendered_html(). */ $request_url = (string) apply_filters( 'abds_seo_local_scan_request_url', $url ); $response = wp_remote_get( $request_url, array( 'timeout' => self::FETCH_TIMEOUT_SECONDS ) ); if ( is_wp_error( $response ) ) { return array( 'html' => '', 'http_status' => 0, ); } $status = (int) wp_remote_retrieve_response_code( $response ); return array( 'html' => 200 === $status ? (string) wp_remote_retrieve_body( $response ) : '', 'http_status' => $status, ); } /** * Inserts every issue this URL's checks produced. * * @param int $run_id Owning audit run. * @param int $audit_url_id Owning audit_urls row. * @param array $issues Issues to persist. */ protected function persist_issues( int $run_id, int $audit_url_id, array $issues ): void { if ( array() === $issues ) { return; } global $wpdb; $issues_table = $wpdb->prefix . 'abds_seo_audit_issues'; $now = current_time( 'mysql' ); foreach ( $issues as $issue ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $issues_table is built only from $wpdb->prefix plus a hardcoded literal, never user input; $wpdb->insert() safely escapes values via the format array. Runs once per issue in a background batch. $wpdb->insert( $issues_table, array( 'run_id' => $run_id, 'audit_url_id' => $audit_url_id, 'issue_key' => $issue['issue_key'], 'category' => $issue['category'], 'severity' => $issue['severity'], 'message' => $issue['message'], 'details_json' => wp_json_encode( $issue['details'] ), 'status' => 'open', 'created_at' => $now, ), array( '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ) ); } } /** * Updates one wp_abds_seo_audit_urls row with its finished check result. * * @param int $audit_url_id Row ID. * @param string $status 'completed' or 'failed'. * @param int $http_status HTTP status code from the fetch, 0 if it never completed. * @param int $score 0-100 score, 0 for a failed fetch. * @param int $issues_count Number of issues found. */ protected function update_url_row( int $audit_url_id, string $status, int $http_status, int $score, int $issues_count ): void { global $wpdb; $urls_table = $wpdb->prefix . 'abds_seo_audit_urls'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $urls_table is built only from $wpdb->prefix plus a hardcoded literal, never user input; $wpdb->update() safely escapes values via the format array. Runs once per URL in a background batch. $wpdb->update( $urls_table, array( 'status' => $status, 'http_status' => $http_status, 'score' => $score, 'issues_count' => $issues_count, 'checked_at' => current_time( 'mysql' ), ), array( 'id' => $audit_url_id ), array( '%s', '%d', '%d', '%d', '%s' ), array( '%d' ) ); } /** * Derives a 0-100 score from the list of issues by deducting each * issue's severity penalty from a perfect 100 — same shape as * Site_Scanner::calculate_score(), scaled to the 5-level penalty table. * * @param array $issues Issues found. * @return int Score between 0 and 100. */ protected function calculate_score( array $issues ): int { $score = 100; foreach ( $issues as $issue ) { $score -= self::SCORE_PENALTY[ $issue['severity'] ] ?? 0; } return max( 0, min( 100, $score ) ); } }