*/ private const EXCLUDED_TYPES = array( 'attachment', 'revision', 'nav_menu_item', 'wp_block' ); /** * Database access for the answer density table. * * @since 1.5.0 * @var AnswerDensityStore */ private $store; /** * Constructor. * * @since 1.5.0 * * @param AnswerDensityStore $store Database access for the answer density table. */ public function __construct( AnswerDensityStore $store ) { $this->store = $store; } /** * Returns the module slug. * * @since 1.5.0 * * @return string Module slug. */ public function slug() { return self::SLUG; } /** * Returns the database store this module reads and writes. * * @since 1.5.0 * * @return AnswerDensityStore Database access for the answer density table. */ public function store() { return $this->store; } /** * Attaches the module's WordPress hooks. * * The save, transition and deletion hooks are attached unconditionally and * check the enable setting inside themselves, for the same reason every * other module here does: hooks register once per request, so deciding at * registration time would freeze the answer for the whole request. * * @since 1.5.0 * * @return void */ public function register_hooks() { add_action( 'save_post', array( $this, 'maybe_scan_on_save' ), 20, 1 ); add_action( 'transition_post_status', array( $this, 'maybe_delete_on_transition' ), 10, 3 ); add_action( 'deleted_post', array( $this, 'delete_density_row' ) ); add_action( Activator::CRON_SCAN_DENSITY, array( $this, 'run_scheduled_scan' ) ); add_action( 'wp_ajax_' . self::AJAX_SCAN, array( $this, 'ajax_scan' ) ); add_action( 'wp_ajax_' . self::AJAX_BULK, array( $this, 'ajax_bulk_scan' ) ); add_filter( 'aive_dashboard_stats', array( $this, 'add_dashboard_stats' ) ); } /** * Scans an eligible item after WordPress has persisted its save. * * @since 1.5.0 * * @param int|mixed $post_id Post id. * @return void */ public function maybe_scan_on_save( $post_id ) { if ( ! Helpers::is_module_enabled( self::SLUG ) ) { return; } $post_id = (int) $post_id; if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) { return; } $post = get_post( $post_id ); if ( ! $post instanceof WP_Post || ! self::is_eligible( $post ) ) { return; } $this->scan_post( $post, true ); } /** * Deletes a post's density row the instant it leaves `publish`. * * @since 1.5.0 * * @param string $new_status New post status. * @param string $old_status Previous post status. * @param WP_Post|null $post Post that changed. * @return void */ public function maybe_delete_on_transition( $new_status, $old_status, $post = null ) { if ( 'publish' !== $old_status || 'publish' === $new_status ) { return; } if ( $post instanceof WP_Post ) { $this->store->delete( (int) $post->ID ); } } /** * Deletes a post's density row when the post itself is deleted. * * @since 1.5.0 * * @param int|mixed $post_id Post id. * @return void */ public function delete_density_row( $post_id ) { $this->store->delete( (int) $post_id ); } /** * Scans a bounded batch of stale or never-scanned eligible posts. * * Runs on the existing `aive_scan_answer_density` six-hour event. Each * post is scanned independently and a failure recording one does not stop * the rest of the batch. * * @since 1.5.0 * * @return void */ public function run_scheduled_scan() { if ( ! Helpers::is_module_enabled( self::SLUG ) ) { return; } $types = self::configured_post_types(); if ( array() === $types ) { return; } $ids = $this->store->stale_batch( $types, self::CRON_BATCH ); if ( null === $ids ) { return; } foreach ( $ids as $id ) { $post = get_post( $id ); if ( ! $post instanceof WP_Post || ! self::is_eligible( $post ) ) { // No longer eligible since it was selected; leave its row alone. continue; } $this->scan_post( $post, true ); } } /** * Scans one post and writes its result. * * @since 1.5.0 * * @param WP_Post $post Eligible post to scan. * @param bool $skip_if_unchanged When true, a matching stored content hash and algorithm version skip the scan. * @return array{result: string, score: int|null} Outcome: `unchanged`, `scanned` or `error`, and the resulting score when one was computed. */ public function scan_post( WP_Post $post, $skip_if_unchanged = false ) { if ( $skip_if_unchanged ) { $existing = $this->store->get( (int) $post->ID ); $content_hash = hash( 'sha256', is_string( $post->post_content ) ? $post->post_content : '' ); if ( null !== $existing && AnswerDensityParser::STATE_SCANNED === $existing['state'] && $existing['content_hash'] === $content_hash && AnswerDensityParser::ALGORITHM_VERSION === $existing['algorithm_version'] ) { return array( 'result' => 'unchanged', 'score' => $existing['score'], ); } } $parsed = AnswerDensityParser::analyze( $post->post_content ); $scanned_at = gmdate( 'Y-m-d H:i:s' ); if ( AnswerDensityParser::STATE_ERROR === $parsed['state'] ) { $this->store->save_error( (int) $post->ID, (string) $post->post_type, $parsed, $scanned_at ); return array( 'result' => 'error', 'score' => null, ); } $summary = AnswerDensityScorer::summarize( $parsed ); $details = AnswerDensityScorer::details_payload( $parsed, $summary ); $this->store->save_scanned( (int) $post->ID, (string) $post->post_type, $summary, $details, $scanned_at ); return array( 'result' => 'scanned', 'score' => $summary['score'], ); } /** * Handles the on-demand single-post scan AJAX request. * * @since 1.5.0 * * @return void */ public function ajax_scan() { $this->guard_ajax(); $post_id = isset( $_POST['post_id'] ) ? absint( wp_unslash( $_POST['post_id'] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Verified by guard_ajax() above. $post = $post_id > 0 ? get_post( $post_id ) : null; if ( ! $post instanceof WP_Post || ! self::is_eligible( $post ) ) { wp_send_json_error( array( 'message' => __( 'That item is not eligible for an Answer Density scan.', 'summitcite' ) ), 404 ); } $outcome = $this->scan_post( $post, false ); wp_send_json_success( $this->ajax_row_payload( $post, $outcome ) ); } /** * Handles one batch of the bulk "Scan All Published Content" AJAX request. * * @since 1.5.0 * * @return void */ public function ajax_bulk_scan() { $this->guard_ajax(); $submitted_token = isset( $_POST['token'] ) && is_scalar( $_POST['token'] ) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Verified by guard_ajax() above. ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- See above. : ''; if ( '' !== $submitted_token ) { if ( 1 !== preg_match( '/^[a-f0-9]{32}$/', $submitted_token ) ) { $this->send_invalid_cursor(); } $job = get_transient( self::BULK_JOB_PREFIX . $submitted_token ); if ( ! is_array( $job ) || ! isset( $job['ids'], $job['offset'], $job['total'] ) ) { $this->send_invalid_cursor(); } $token = $submitted_token; } else { $types = self::configured_post_types(); $ids = array() === $types ? array() : $this->store->eligible_snapshot( $types ); $ids = null === $ids ? array() : $ids; $token = bin2hex( random_bytes( 16 ) ); $job = array( 'ids' => $ids, 'offset' => 0, 'total' => count( $ids ), ); } $batch = array_slice( $job['ids'], $job['offset'], self::BULK_BATCH ); $succeeded = 0; $skipped = 0; $failures = array(); foreach ( $batch as $post_id ) { $post = get_post( (int) $post_id ); if ( ! $post instanceof WP_Post || ! self::is_eligible( $post ) ) { ++$skipped; continue; } $outcome = $this->scan_post( $post, false ); if ( 'error' === $outcome['result'] ) { $failures[] = array( 'post_id' => (int) $post_id, 'reason' => AnswerDensityParser::DIAGNOSTIC_CONTENT_LIMIT, ); continue; } ++$succeeded; } $job['offset'] += count( $batch ); $processed = $job['offset']; $total = $job['total']; $done = $processed >= $total; if ( $done ) { delete_transient( self::BULK_JOB_PREFIX . $token ); $next_batch = null; } else { set_transient( self::BULK_JOB_PREFIX . $token, $job, self::BULK_JOB_TTL ); $next_batch = $token; } wp_send_json_success( array( 'processed' => $processed, 'total' => $total, 'next_batch' => $next_batch, 'succeeded' => $succeeded, 'skipped' => $skipped, 'failures' => $failures, ) ); } /** * Sends the safe "invalid or expired" bulk-scan response and exits. * * Never scans anything: an unrecognised or expired cursor must fail * closed rather than fall back to a fresh snapshot silently. * * @since 1.5.0 * * @return never */ private function send_invalid_cursor() { wp_send_json_error( array( 'message' => __( 'That scan session is invalid or has expired. Start a new scan.', 'summitcite' ), 'code' => 'cursor_invalid', ), 400 ); } /** * Verifies the capability and nonce every AJAX endpoint here requires. * * Ends the request on failure, the same way `check_ajax_referer()` with * its default `$die` argument does, but with a capability check ahead of * it so a request from a user who should never have reached the nonce * check is rejected before anything scans. * * @since 1.5.0 * * @return void */ private function guard_ajax() { if ( ! current_user_can( Admin::CAPABILITY ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission to run an Answer Density scan.', 'summitcite' ) ), 403 ); } check_ajax_referer( self::NONCE_ACTION, 'nonce' ); if ( ! Helpers::is_module_enabled( self::SLUG ) ) { wp_send_json_error( array( 'message' => __( 'Answer Density is switched off.', 'summitcite' ) ), 400 ); } } /** * Builds the AJAX response payload for one freshly scanned post. * * @since 1.5.0 * * @param WP_Post $post Scanned post. * @param array{result: string, score: int|null} $outcome Outcome from scan_post(). * @return array Response payload. */ private function ajax_row_payload( WP_Post $post, array $outcome ) { $row = $this->store->get( (int) $post->ID ); $breakdown = null === $row ? array() : $row['score_breakdown']; return array( 'post_id' => (int) $post->ID, 'result' => $outcome['result'], 'score' => $outcome['score'], 'state' => null === $row ? null : $row['state'], 'questions' => null === $row ? null : $row['question_headings'], 'direct' => null === $row ? null : $row['direct_answers'], 'buried' => null === $row ? null : $row['buried_answers'], 'unanswered' => null === $row ? null : $row['derivative_answers'], 'algorithm_version' => null === $row ? null : $row['algorithm_version'], 'coverage_raw' => isset( $breakdown['coverage_raw'] ) ? round( (float) $breakdown['coverage_raw'], 2 ) : null, 'coverage_points' => isset( $breakdown['coverage_points'] ) ? round( (float) $breakdown['coverage_points'], 1 ) : null, 'quality_points' => isset( $breakdown['quality_points'] ) ? round( (float) $breakdown['quality_points'], 1 ) : null, 'structure_points' => isset( $breakdown['structure_points'] ) ? round( (float) $breakdown['structure_points'], 1 ) : null, ); } /** * Adds this module's aggregates to the shared dashboard stats array. * * Published unconditionally, including while the module is disabled, for * the reason CrawlerLog's equivalent method states: these keys describe * the rows that exist, not whether scanning is currently running. * * @since 1.5.0 * * @param array|mixed $stats Stats collected so far. * @return array Stats including this module's keys. */ public function add_dashboard_stats( $stats ) { if ( ! is_array( $stats ) ) { $stats = array(); } $types = self::configured_post_types(); $summary = array() === $types ? null : $this->store->summary( $types ); if ( null === $summary ) { $summary = array( 'average_score' => null, 'scanned_count' => 0, 'breakdown' => array(), ); } $stats['answer_density_average_score'] = null === $summary['average_score'] ? null : round( $summary['average_score'], 1 ); $stats['answer_density_scanned_count'] = $summary['scanned_count']; $stats['answer_density_type_breakdown'] = $summary['breakdown']; return $stats; } /** * Reports whether a post is currently eligible for answer density scoring. * * A password-protected published post is eligible: scoring happens in * administration and discloses nothing publicly, unlike a frontend output * feature that would need to exclude it. * * @since 1.5.0 * * @param WP_Post $post Candidate post. * @return bool True when the post may be scanned. */ public static function is_eligible( WP_Post $post ) { if ( 'publish' !== $post->post_status ) { return false; } if ( wp_is_post_revision( $post ) || wp_is_post_autosave( $post ) ) { return false; } return in_array( $post->post_type, self::configured_post_types(), true ); } /** * Returns the post types that may be configured at all. * * `post` and `page` are always offered, matching the contract's stated * defaults; core does not mark `page` as `publicly_queryable`, which is why * this follows SchemaEngine::eligible_post_types()'s precedent of testing * `public` instead. Every other public type is offered except the four the * contract names as never eligible. * * @since 1.5.0 * * @return array Post type names that may be configured. */ public static function allowed_post_types() { $types = array( 'post', 'page' ); foreach ( get_post_types( array( 'public' => true ), 'names' ) as $type ) { if ( in_array( $type, self::EXCLUDED_TYPES, true ) || in_array( $type, $types, true ) ) { continue; } $types[] = $type; } return $types; } /** * Returns the post types answer density is currently configured to scan. * * The stored setting is reduced to types that are still allowed, so a type * removed from the registry or made non-public between saves cannot keep * posts flowing through scans, queries or stats for it. * * @since 1.5.0 * * @return array Currently configured and allowed post types. */ public static function configured_post_types() { $configured = Helpers::get_setting( 'answer_density_post_types', array( 'post', 'page' ) ); $configured = is_array( $configured ) ? $configured : array(); $configured = array_values( array_filter( $configured, 'is_string' ) ); return array_values( array_intersect( self::allowed_post_types(), $configured ) ); } /** * Reports whether a post type name may be configured. * * @since 1.5.0 * * @param string $type Candidate post type name. * @return bool True when the type may be configured. */ public static function is_allowed_post_type( $type ) { return is_string( $type ) && in_array( $type, self::allowed_post_types(), true ); } }