20, self::SEVERITY_WARNING => 10, self::SEVERITY_INFO => 5, ); const THIN_CONTENT_WORD_THRESHOLD = 300; const TITLE_MAX_LENGTH = 60; const DESCRIPTION_MAX_LENGTH = 160; /** * Scans a single post/page and returns its list of issues plus a * derived 0-100 technical score. Also persists the result to * wp_abds_seo_page_scores. * * @param int $post_id Post ID to scan. * @param string|null $prefetched_html Optional already-fetched rendered HTML for * this post, reused by check_schema() instead * of it fetching its own copy — lets a caller * that needs the same page's HTML for other * checks too (R3's Audit Engine) share one HTTP * round trip instead of each check fetching its * own. Null (the default) preserves today's * behavior exactly: check_schema() fetches for * itself, same as every existing caller expects. * @return array{post_id:int, technical_score:int, issues:array} */ public function scan_post( int $post_id, ?string $prefetched_html = null ): array { $post = get_post( $post_id ); if ( ! $post || 'publish' !== $post->post_status ) { return array( 'post_id' => $post_id, 'technical_score' => 0, 'issues' => array(), ); } $issues = array_merge( $this->check_meta( $post_id, $post ), $this->check_headings( $post ), $this->check_images( $post ), $this->check_indexation( $post_id ), $this->check_content_length( $post ), $this->check_schema( $post_id, $prefetched_html ) ); $score = $this->calculate_score( $issues ); $this->persist_result( $post_id, $score, $issues ); return array( 'post_id' => $post_id, 'technical_score' => $score, 'issues' => $issues, ); } /** * Checks title/meta-description presence and length. * * @param int $post_id Post ID. * @param WP_Post $post Post object. * @return array */ protected function check_meta( int $post_id, WP_Post $post ): array { $issues = array(); $title = get_post_meta( $post_id, '_abds_seo_title', true ); $title = '' !== $title ? $title : $post->post_title; $description = get_post_meta( $post_id, '_abds_seo_description', true ); if ( '' === trim( (string) $title ) ) { $issues[] = $this->issue( 'missing_title', self::SEVERITY_CRITICAL, __( 'The page title is missing.', 'abds-seo' ) ); } elseif ( mb_strlen( $title ) > self::TITLE_MAX_LENGTH ) { $issues[] = $this->issue( 'title_too_long', self::SEVERITY_INFO, __( 'The title exceeds the recommended length (60 characters).', 'abds-seo' ) ); } if ( '' === trim( (string) $description ) ) { $issues[] = $this->issue( 'missing_meta_description', self::SEVERITY_WARNING, __( 'The meta description is missing.', 'abds-seo' ) ); } elseif ( mb_strlen( $description ) > self::DESCRIPTION_MAX_LENGTH ) { $issues[] = $this->issue( 'description_too_long', self::SEVERITY_INFO, __( 'The meta description exceeds the recommended length (160 characters).', 'abds-seo' ) ); } return $issues; } /** * Checks H1 presence/uniqueness. * * @param WP_Post $post Post object. * @return array */ protected function check_headings( WP_Post $post ): array { $issues = array(); $h1_count = preg_match_all( '/]/i', $post->post_content ); if ( 0 === $h1_count ) { $issues[] = $this->issue( 'missing_h1', self::SEVERITY_WARNING, __( 'The page has no H1 heading.', 'abds-seo' ) ); } elseif ( $h1_count > 1 ) { $issues[] = $this->issue( 'multiple_h1', self::SEVERITY_WARNING, __( 'The page has multiple H1 headings.', 'abds-seo' ) ); } return $issues; } /** * Checks for images missing an alt attribute. * * @param WP_Post $post Post object. * @return array */ protected function check_images( WP_Post $post ): array { $issues = array(); if ( ! preg_match_all( '/]*>/i', $post->post_content, $matches ) ) { return $issues; } $missing_alt = 0; foreach ( $matches[0] as $img_tag ) { if ( ! preg_match( '/\balt\s*=\s*["\'][^"\']+["\']/i', $img_tag ) ) { ++$missing_alt; } } if ( $missing_alt > 0 ) { $issues[] = $this->issue( 'missing_alt', self::SEVERITY_WARNING, sprintf( /* translators: %d: number of images missing an alt attribute. */ _n( '%d image without an alt attribute.', '%d images without an alt attribute.', $missing_alt, 'abds-seo' ), $missing_alt ) ); } return $issues; } /** * Checks canonical URL presence. * * @param int $post_id Post ID. * @return array */ protected function check_indexation( int $post_id ): array { $issues = array(); $canonical = get_post_meta( $post_id, '_abds_seo_canonical', true ); if ( '' === trim( (string) $canonical ) ) { $issues[] = $this->issue( 'missing_canonical', self::SEVERITY_INFO, __( 'No canonical URL is set.', 'abds-seo' ) ); } return $issues; } /** * Checks whether the post's word count is below the thin-content threshold. * * @param WP_Post $post Post object. * @return array */ protected function check_content_length( WP_Post $post ): array { $issues = array(); $plain_text = wp_strip_all_tags( $post->post_content ); $word_count = str_word_count( $plain_text ); if ( $word_count < self::THIN_CONTENT_WORD_THRESHOLD ) { $issues[] = $this->issue( 'thin_content', self::SEVERITY_WARNING, sprintf( /* translators: %d: minimum recommended word count. */ __( 'The content is too short (fewer than %d words).', 'abds-seo' ), self::THIN_CONTENT_WORD_THRESHOLD ) ); } return $issues; } /** * Validates the page's structured data (Article/Product/FAQ/Breadcrumb * rich-result types, plus the LocalBusiness schema from Stage 4.6) by * fetching the rendered page — schema is injected at render time by this * plugin's frontend output or the theme, so raw post_content can't show * it. One fetch feeds both validators. * * A page with no schema of these types is not flagged: absence isn't * invalidity, and most pages legitimately carry none. Only schema that * IS present but broken is reported — a half-built rich result that * silently won't render is exactly the kind of thing a scanner should * surface. Runs only inside the background scan batch, never on a * visitor request. * * @param int $post_id Post ID. * @param string|null $prefetched_html Optional already-fetched HTML, see scan_post()'s docblock. * @return array */ protected function check_schema( int $post_id, ?string $prefetched_html = null ): array { /** * Filters whether the scanner fetches each page to validate its * structured data. On by default; a site that doesn't want the extra * per-page request during a scan can turn it off. * * @since 0.4.5 * * @param bool $enabled Whether to run the schema check. * @param int $post_id Post being scanned. */ if ( ! apply_filters( 'abds_seo_scan_validate_schema', true, $post_id ) ) { return array(); } $html = $prefetched_html ?? $this->fetch_rendered_html( $post_id ); if ( '' === $html ) { return array(); } $issues = array(); foreach ( ABDS_SEO_Schema_Validator::validate_html( $html ) as $result ) { if ( $result['valid'] ) { continue; } $issues[] = $this->issue( 'schema_invalid_' . strtolower( $result['type'] ), self::SEVERITY_WARNING, sprintf( /* translators: 1: schema type, 2: comma-separated missing fields. */ __( 'The %1$s markup is incomplete for a rich result: %2$s', 'abds-seo' ), $result['type'], implode( ', ', $result['errors'] ) ) ); } return array_merge( $issues, $this->check_local_business_schema( $html ) ); } /** * Validates a LocalBusiness node when the page carries one, reusing the * Stage 4.6 validator so "is there a LocalBusiness and is it complete" * is answered one way across the plugin. * * @param string $html Rendered page HTML. * @return array */ protected function check_local_business_schema( string $html ): array { $blocks = ABDS_SEO_LocalBusiness_Validator::extract_jsonld_blocks( $html ); $node = ABDS_SEO_LocalBusiness_Validator::find_local_business( $blocks ); if ( null === $node ) { return array(); } $result = ABDS_SEO_LocalBusiness_Validator::validate( $node ); if ( $result['valid'] ) { return array(); } return array( $this->issue( 'schema_invalid_localbusiness', self::SEVERITY_WARNING, sprintf( /* translators: %s: comma-separated missing fields. */ __( 'The LocalBusiness markup is incomplete: %s', 'abds-seo' ), implode( ', ', $result['errors'] ) ) ), ); } /** * Fetches a post's rendered HTML for schema validation. Wrapped so the * scan can be exercised without a live HTTP round trip, and reusing the * loopback rewrite filter the local-SEO scanner already defines for * containerized dev stacks where home_url() doesn't resolve internally. * * @param int $post_id Post ID. * @return string Rendered HTML, or '' on any failure. */ protected function fetch_rendered_html( int $post_id ): string { $permalink = (string) get_permalink( $post_id ); if ( '' === $permalink ) { return ''; } /** This filter is documented in ABDS_SEO_Local_SEO_Scanner::run(). */ $request_url = (string) apply_filters( 'abds_seo_local_scan_request_url', $permalink ); $response = wp_remote_get( $request_url, array( 'timeout' => 30 ) ); if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { return ''; } return (string) wp_remote_retrieve_body( $response ); } /** * Builds a single issue entry. * * @param string $code Machine-readable issue code. * @param string $severity One of the SEVERITY_* constants. * @param string $message Human-readable, translated message. * @return array{code:string, severity:string, message:string} */ protected function issue( string $code, string $severity, string $message ): array { return array( 'code' => $code, 'severity' => $severity, 'message' => $message, ); } /** * Derives a 0-100 score from the list of issues by deducting each * issue's severity penalty from a perfect 100. * * @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 ) ); } /** * Upserts the scan result into wp_abds_seo_page_scores. * * @param int $post_id Post ID. * @param int $score Technical score, 0-100. * @param array $issues Issues found. */ protected function persist_result( int $post_id, int $score, array $issues ): void { global $wpdb; $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 -- $table is built only from $wpdb->prefix plus a hardcoded literal, never user input; $post_id is passed through $wpdb->prepare(). Runs once per scanned page in a background batch, not on a cacheable read path. $existing_id = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$table} WHERE post_id = %d", $post_id ) ); $data = array( 'post_id' => $post_id, 'technical_score' => $score, 'issues_json' => wp_json_encode( $issues ), 'last_scanned' => current_time( 'mysql' ), ); $format = array( '%d', '%f', '%s', '%s' ); // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table is built only from $wpdb->prefix plus a hardcoded literal, never user input; $wpdb->update()/insert() safely escape their values via the format array. Runs once per scanned page in a background batch, not a cacheable read path. if ( $existing_id ) { $wpdb->update( $table, $data, array( 'id' => $existing_id ), $format, array( '%d' ) ); } else { $wpdb->insert( $table, $data, $format ); } // phpcs:enable } }