$cursor Cursor with the last post id seen. * @return array|null */ public function step( int $scan_id, array $cursor ): ?array { global $wpdb; $last = (int) ( $cursor['last'] ?? 0 ); if ( 0 === $last ) { $this->check_tagline( $scan_id ); } $rows = (array) $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Reads posts in batches. $wpdb->prepare( "SELECT ID, post_type, post_name, post_title, post_content, post_modified_gmt FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN ('post','page') AND ID > %d ORDER BY ID ASC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Core table name. $last, self::PER_STEP ) ); $profile = self::business_details(); $seen = array(); foreach ( (array) ( $cursor['seen'] ?? array() ) as $hash => $post ) { $seen[ (int) $hash ] = (int) $post; } foreach ( $rows as $row ) { $this->inspect( $scan_id, $row, $profile, $seen ); $last = (int) $row->ID; } return count( $rows ) < self::PER_STEP ? null : array( 'last' => $last, 'seen' => $seen, ); } /** * The business details, when the Business details app is active and has a phone number or * an email address to compare with. * * @return array|null */ private static function business_details(): ?array { /** * Filters the business details. The Business details app provides them while it is active. * * @param array|null $details Details, null when none. */ $details = apply_filters( 'forensix_business_details', null ); return is_array( $details ) && BusinessConfig::has_contacts( $details ) ? $details : null; } /** * Months after which a post counts as not updated for a long time. */ public static function stale_months(): int { /** * Filters the number of months after which a post is suggested for review. * * @param int $months Months. */ return max( 1, (int) apply_filters( 'forensix_content_stale_months', ContentRules::STALE_MONTHS ) ); } /** * Reports the tagline WordPress sets on installation. * * @param int $scan_id Scan id. */ private function check_tagline( int $scan_id ): void { if ( ContentRules::DEFAULT_TAGLINE !== (string) get_option( 'blogdescription' ) ) { return; } $source = new Source( Source::OPTION, 'blogdescription' ); $this->store->record( $scan_id, $this->app, new Observation( 'content.default_tagline', Priority::LOW, Priority::ERROR, __( 'The site still has the default tagline', 'forensix-digital' ), home_url( '/' ), $source->type, $source->ref, __( 'Site tagline', 'forensix-digital' ), array() ), admin_url( 'options-general.php' ), ContentRules::DEFAULT_TAGLINE ); } /** * The Elementor elements of a post, or none when it is not built with Elementor. * * @param int $id Post id. * @return array */ private function elementor_elements( int $id ): array { $data = get_post_meta( $id, '_elementor_data', true ); $data = is_string( $data ) && '' !== $data ? json_decode( $data, true ) : $data; return is_array( $data ) ? $data : array(); } /** * Checks one post. * * @param int $scan_id Scan id. * @param \stdClass $row Post row. * @param array|null $profile Business details, null when not used. * @param array $seen Paragraph hash => id of the first post that has it; extended here. */ private function inspect( int $scan_id, \stdClass $row, ?array $profile, array &$seen ): void { $id = (int) $row->ID; $title = (string) $row->post_title; $html = (string) $row->post_content; if ( ContentRules::is_default_post( (string) $row->post_type, (string) $row->post_name, $title ) ) { $this->record( $scan_id, $id, 'default_content', Priority::MEDIUM, Priority::ERROR, __( 'This is the sample content WordPress adds to a new site', 'forensix-digital' ), array(), $title ); } $placeholders = array_unique( array_merge( ContentRules::placeholders( $title ), ContentRules::placeholders( $html ) ) ); if ( array() !== $placeholders ) { $this->record( $scan_id, $id, 'placeholder_text', Priority::MEDIUM, Priority::ERROR, __( 'This page contains placeholder text', 'forensix-digital' ), array( 'kinds' => array_values( $placeholders ) ), implode( ', ', array_map( static fn( string $kind ): string => 'lorem' === $kind ? __( 'Lorem ipsum text', 'forensix-digital' ) : __( 'placeholder email address', 'forensix-digital' ), $placeholders ) ) ); } $empty = ContentRules::empty_elements( $html ) + ContentRules::empty_containers( $this->elementor_elements( $id ) ); if ( $empty > 0 ) { $this->record( $scan_id, $id, 'empty_element', Priority::LOW, Priority::SUGGESTION, __( 'This page has empty paragraphs, headings or Elementor sections', 'forensix-digital' ), array( 'count' => $empty ), /* translators: %d: number of empty elements. */ sprintf( _n( '%d empty element', '%d empty elements', $empty, 'forensix-digital' ), $empty ) ); } if ( null !== $profile ) { $wrong = ContactConsistency::mismatches( $html, $profile, (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); $found = array_merge( $wrong['phones'], $wrong['emails'] ); if ( array() !== $found ) { $this->record( $scan_id, $id, 'contact_mismatch', Priority::MEDIUM, Priority::ERROR, __( 'This page uses a phone number or email address that is not in your business details', 'forensix-digital' ), $wrong, implode( ', ', $found ) ); } } $repeated = $this->repeated_paragraphs( $id, $html, $seen ); if ( array() !== $repeated ) { $other = (int) reset( $repeated ); $this->record( $scan_id, $id, 'duplicate_text', Priority::LOW, Priority::SUGGESTION, __( 'Paragraphs on this page also appear on another page', 'forensix-digital' ), array( 'count' => count( $repeated ), 'other' => $other, ), /* translators: 1: number of paragraphs, 2: title of the other page. */ sprintf( _n( '%1$d paragraph also appears on "%2$s"', '%1$d paragraphs also appear on "%2$s"', count( $repeated ), 'forensix-digital' ), count( $repeated ), get_the_title( $other ) ) ); } $dates = ContentRules::past_event_dates( $html, time() ); if ( array() !== $dates ) { $this->record( $scan_id, $id, 'past_date', Priority::LOW, Priority::SUGGESTION, __( 'This page mentions a date that has passed', 'forensix-digital' ), array( 'dates' => $dates ), implode( ', ', $dates ) ); } $modified = (int) strtotime( (string) $row->post_modified_gmt . ' UTC' ); if ( 'post' === $row->post_type && ContentRules::is_stale( $modified, time(), self::stale_months() ) ) { $this->record( $scan_id, $id, 'stale', Priority::LOW, Priority::SUGGESTION, __( 'This post has not been updated for a long time', 'forensix-digital' ), array( 'modified' => $modified ), /* translators: %s: date of the last change. */ sprintf( __( 'Last changed %s', 'forensix-digital' ), wp_date( get_option( 'date_format' ), $modified ) ) ); } } /** * Paragraphs of a post that an earlier post already has. The first post to have a paragraph * keeps it; later posts are the ones reported. * * @param int $id Post id. * @param string $html Post content. * @param array $seen Paragraph hash => id of the first post; extended here. * @return array Hash => id of the other post, for the repeated paragraphs. */ private function repeated_paragraphs( int $id, string $html, array &$seen ): array { $repeated = array(); foreach ( ContentRules::paragraph_hashes( $html ) as $hash ) { if ( isset( $seen[ $hash ] ) && $seen[ $hash ] !== $id ) { $repeated[ $hash ] = $seen[ $hash ]; } elseif ( ! isset( $seen[ $hash ] ) && count( $seen ) < self::DUPLICATE_CAP ) { $seen[ $hash ] = $id; } } return $repeated; } /** * Records one finding. * * @param int $scan_id Scan id. * @param int $id Post id. * @param string $name Check name after "content.". * @param string $level Severity. * @param string $type Finding type. * @param string $title Plain sentence. * @param array $details Facts for the explanation. * @param string $context Short note shown with the finding. */ private function record( int $scan_id, int $id, string $name, string $level, string $type, string $title, array $details, string $context ): void { $source = new Source( Source::POST_CONTENT, (string) $id ); $link = get_permalink( $id ); $this->store->record( $scan_id, $this->app, new Observation( 'content.' . $name, $level, $type, $title, false !== $link ? $link : home_url( '/' ), $source->type, $source->ref, ( new SourceLabels() )->label( $source ), $details ), (string) get_edit_post_link( $id, 'raw' ), $context ); } }