$cursor */ public function step( int $scan_id, array $cursor, int $max_urls ): array { $stage = (int) ( $cursor['stage'] ?? self::STAGE_FRONT ); $after = (int) ( $cursor['after'] ?? 0 ); $capped = (bool) ( $cursor['capped'] ?? false ); $room = $max_urls - $this->queue->count( $scan_id ); if ( $room <= 0 ) { return array( 'stage' => self::STAGE_DONE, 'after' => 0, 'capped' => $capped || self::STAGE_DONE !== $stage, ); } switch ( $stage ) { case self::STAGE_FRONT: $rows = array( $this->row( home_url( '/' ), 'front_page', Priority::IMPORTANCE_FRONT_PAGE ) ); $next = array( self::STAGE_MENUS, 0 ); break; case self::STAGE_MENUS: $rows = $this->menu_rows(); $next = array( self::STAGE_POSTS, 0 ); break; case self::STAGE_POSTS: [ $rows, $last ] = $this->post_rows( $after ); $next = null === $last ? array( self::STAGE_TERMS, 0 ) : array( self::STAGE_POSTS, $last ); break; case self::STAGE_TERMS: [ $rows, $last ] = $this->term_rows( $after ); $next = null === $last ? array( self::STAGE_DONE, 0 ) : array( self::STAGE_TERMS, $last ); break; default: return array( 'stage' => self::STAGE_DONE, 'after' => 0, 'capped' => $capped, ); } $rows = array_values( array_filter( $rows ) ); if ( count( $rows ) > $room ) { $rows = array_slice( $rows, 0, $room ); $capped = true; } $this->queue->add( $scan_id, $rows ); return array( 'stage' => $next[0], 'after' => $next[1], 'capped' => $capped, ); } /** * Whether a URL is never scanned: other hosts, admin, login, XML-RPC, REST API, feeds of comments. * * @param string $url Normalised URL. * @param string[] $hosts Site hosts. */ public static function is_excluded( string $url, array $hosts ): bool { if ( ! Url::is_internal( $url, $hosts ) ) { return true; } $path = (string) wp_parse_url( $url, PHP_URL_PATH ); return 1 === preg_match( '#/(wp-admin|wp-json)(/|$)|/(wp-login|xmlrpc|wp-cron|wp-signup|wp-activate)\.php$#', $path ); } /** * One queue row, or null for URLs that are not scanned. * * @param string $url URL. * @param string $origin Where it came from. * @param int $importance Page importance. * @param string $status Initial status. * @param string $reason Skip reason. * @return array{url: string, origin: string, importance: int, status: string, reason: string}|null */ private function row( string $url, string $origin, int $importance, string $status = ScanQueue::PENDING, string $reason = '' ): ?array { $normal = Url::normalize( $url ); if ( null === $normal || self::is_excluded( $normal, $this->hosts ) ) { return null; } return array( 'url' => $normal, 'origin' => $origin, 'importance' => $importance, 'status' => $status, 'reason' => $reason, ); } /** * Internal links of menus assigned to a theme location. * * @return array */ private function menu_rows(): array { $rows = array(); foreach ( array_unique( array_filter( array_map( 'intval', get_nav_menu_locations() ) ) ) as $menu_id ) { $items = wp_get_nav_menu_items( $menu_id ); if ( ! is_array( $items ) ) { continue; } foreach ( $items as $item ) { $url = (string) ( $item->url ?? '' ); if ( '' !== $url ) { $rows[] = $this->row( $url, 'menu', Priority::IMPORTANCE_IN_MENU ); } } } return $rows; } /** * Published posts of public types after an id. * * @param int $after Last post id handled. * @return array{0: array, 1: int|null} Rows and the last id, null when no posts remain. */ private function post_rows( int $after ): array { global $wpdb; $types = array_values( array_diff( get_post_types( array( 'public' => true ) ), array( 'attachment' ) ) ); if ( array() === $types ) { return array( array(), null ); } $placeholders = implode( ', ', array_fill( 0, count( $types ), '%s' ) ); // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- One %s per post type, built above. $posts = $wpdb->get_results( $wpdb->prepare( "SELECT ID, post_type, post_password FROM %i WHERE post_status = 'publish' AND post_type IN ({$placeholders}) AND ID > %d ORDER BY ID LIMIT %d", array_merge( array( $wpdb->posts ), $types, array( $after, self::BATCH ) ) ) ); // phpcs:enable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared if ( ! is_array( $posts ) || array() === $posts ) { return array( array(), null ); } $rows = array(); $last = $after; foreach ( $posts as $post ) { $last = (int) $post->ID; $permalink = get_permalink( $last ); if ( ! is_string( $permalink ) ) { continue; } $rows[] = '' !== (string) $post->post_password ? $this->row( $permalink, (string) $post->post_type, Priority::IMPORTANCE_DEFAULT, ScanQueue::SKIPPED, 'password' ) : $this->row( $permalink, (string) $post->post_type, Priority::IMPORTANCE_DEFAULT ); } return array( $rows, $last ); } /** * Archive pages of public taxonomy terms that have posts, after a term id. * * @param int $after Last term id handled. * @return array{0: array, 1: int|null} Rows and the last id, null when no terms remain. */ private function term_rows( int $after ): array { global $wpdb; $taxonomies = array_values( array_diff( get_taxonomies( array( 'public' => true ) ), array( 'post_format' ) ) ); if ( array() === $taxonomies ) { return array( array(), null ); } $placeholders = implode( ', ', array_fill( 0, count( $taxonomies ), '%s' ) ); // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- One %s per taxonomy, built above. $terms = $wpdb->get_results( $wpdb->prepare( "SELECT term_id, taxonomy FROM %i WHERE taxonomy IN ({$placeholders}) AND count > 0 AND term_id > %d ORDER BY term_id LIMIT %d", array_merge( array( $wpdb->term_taxonomy ), $taxonomies, array( $after, self::BATCH ) ) ) ); // phpcs:enable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared if ( ! is_array( $terms ) || array() === $terms ) { return array( array(), null ); } $rows = array(); $last = $after; foreach ( $terms as $term ) { $last = (int) $term->term_id; $link = get_term_link( $last, (string) $term->taxonomy ); if ( is_string( $link ) ) { $rows[] = $this->row( $link, (string) $term->taxonomy, Priority::IMPORTANCE_DEFAULT ); } } return array( $rows, $last ); } }