settings = $settings; $this->types = $types; $this->resolver = $resolver; $this->head = $head; $this->jobs = $jobs; $this->extra_site = $extra_site; } /** * Registers the job handler. */ public function register() { $this->jobs->register( self::JOB, array( $this, 'step' ), 1 ); } /** * Starts a snapshot unless one is running. * * @param string $source manual|scheduled. * @return bool */ public function start( $source = 'manual' ) { if ( ! Tables::v4_ready() ) { return false; } $state = $this->jobs->status( self::JOB ); if ( $state && 'running' === $state['status'] ) { return false; } update_option( self::SOURCE_OPTION, 'scheduled' === $source ? 'scheduled' : 'manual', false ); return $this->jobs->start( self::JOB ); } /** * Job handler. * * @param mixed $cursor Cursor. * @return array{cursor:array,done:bool} */ public function step( $cursor ) { global $wpdb; if ( ! is_array( $cursor ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->insert( Tables::snapshots(), array( 'created_at' => current_time( 'mysql', true ), 'source' => (string) get_option( self::SOURCE_OPTION, 'manual' ), 'status' => 'running', 'site' => wp_json_encode( $this->site_signals() ), ) ); return array( 'cursor' => array( 'id' => (int) $wpdb->insert_id, 'stage' => 'posts', 'last' => 0, ), 'done' => false, ); } if ( 'posts' === $cursor['stage'] ) { $r = $this->posts_batch( $cursor['id'], (int) $cursor['last'], 100 ); $cursor['last'] = $r['last']; if ( $r['done'] ) { $cursor['stage'] = 'rendered'; $cursor['urls'] = $this->head->sample_urls(); $cursor['pos'] = 0; } } elseif ( 'rendered' === $cursor['stage'] ) { $total = count( $cursor['urls'] ); for ( $i = 0; $i < 2 && $cursor['pos'] < $total; $i++ ) { $this->rendered_item( $cursor['id'], $cursor['urls'][ $cursor['pos'] ] ); ++$cursor['pos']; } if ( $cursor['pos'] >= $total ) { $this->finish( $cursor['id'] ); return array( 'cursor' => $cursor, 'done' => true, ); } } return array( 'cursor' => $cursor, 'done' => false, ); } /** * Site-wide signals. * * @return array */ public function site_signals() { $settings = $this->settings->all(); $robots = wp_remote_get( home_url( '/robots.txt' ), array( 'timeout' => 10, 'sslverify' => apply_filters( 'https_local_ssl_verify', false ), // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core filter. 'headers' => array( \Orbitstem\SEO\Diagnostics\UrlChecker::CHECK_HEADER => '1' ), ) ); return array( 'blog_public' => (int) get_option( 'blog_public' ), 'permalink_structure' => (string) get_option( 'permalink_structure' ), 'theme' => get_stylesheet(), 'sitemap_enabled' => (bool) $settings['sitemap_enabled'], 'settings_hash' => md5( wp_json_encode( $settings ) ), 'robots_txt_hash' => is_wp_error( $robots ) ? '' : md5( (string) wp_remote_retrieve_body( $robots ) ), ) + (array) call_user_func( $this->extra_site ); } /** * Records one batch of published items. * * @param int $snapshot_id Snapshot. * @param int $after Last post ID. * @param int $limit Batch size. * @return array{last:int,done:bool} */ private function posts_batch( $snapshot_id, $after, $limit ) { global $wpdb; $types = array_keys( $this->types->post_types() ); if ( ! $types ) { return array( 'last' => $after, 'done' => true, ); } $in = implode( ',', array_fill( 0, count( $types ), '%s' ) ); $args = array_merge( $types, array( (int) $after, (int) $limit ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter -- one %s per type, built above. $ids = array_map( 'intval', $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN ({$in}) AND ID > %d ORDER BY ID ASC LIMIT %d", ...$args ) ) ); if ( ! $ids ) { return array( 'last' => $after, 'done' => true, ); } _prime_post_caches( $ids, false, true ); $inbound = $this->inbound_counts( $ids ); $home = home_url( '/' ); $rows = array(); foreach ( $ids as $id ) { $post = get_post( $id ); $data = $post ? $this->resolver->for_post( $post ) : null; if ( ! $data ) { continue; } $url = Url::normalize( (string) get_permalink( $post ), $home ); $canonical = '' !== $data['canonical'] ? Url::normalize( $data['canonical'], $home ) : $url; $sitemap = $this->settings->get( 'sitemap_enabled' ) && $this->settings->post_type( $post->post_type, 'sitemap' ) && ! $data['noindex'] && $canonical === $url && '' === $post->post_password; $rows[] = $wpdb->prepare( '(%d, 1, %s, %s, %d, 0, %d, %s, %s, %s, %d, %d, 0)', $snapshot_id, Url::hash( $url ), mb_substr( $url, 0, 500 ), $id, $data['noindex'] ? 1 : 0, mb_substr( $canonical, 0, 500 ), mb_substr( TemplateEngine::plain( $data['title'] ), 0, 255 ), md5( $data['description'] ), $sitemap ? 1 : 0, isset( $inbound[ $id ] ) ? $inbound[ $id ] : 0 ); } if ( $rows ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- each row prepared above. $wpdb->query( $wpdb->prepare( 'INSERT INTO %i (snapshot_id, kind, url_hash, url, object_id, status, noindex, canonical, title, desc_hash, sitemap, inbound, duplicates) VALUES ', Tables::snapshot_items() ) . implode( ',', $rows ) ); } return array( 'last' => end( $ids ), 'done' => count( $ids ) < $limit, ); } /** * Number of other posts linking to each post (from the link index). * * @param int[] $ids Post IDs. * @return array */ private function inbound_counts( array $ids ) { global $wpdb; if ( ! Tables::exist_cached() || ! $ids ) { return array(); } $in = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); $args = array_merge( array( Tables::links() ), $ids ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter -- one %d per ID, built above. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT target_id, COUNT(DISTINCT source_id) AS n FROM %i WHERE kind = 'link' AND source_id <> target_id AND target_id IN ({$in}) GROUP BY target_id", ...$args ) ); $counts = array(); foreach ( $rows as $row ) { $counts[ (int) $row->target_id ] = (int) $row->n; } return $counts; } /** * Records one rendered page. * * @param int $snapshot_id Snapshot. * @param string $url Page URL. */ private function rendered_item( $snapshot_id, $url ) { global $wpdb; $page = $this->head->inspect( $url ); $normalized = Url::normalize( $url, home_url( '/' ) ); $noindex = isset( $page['robots_values'] ) && preg_grep( '/noindex/', $page['robots_values'] ); $noindex = $noindex || ( isset( $page['x_robots'] ) && false !== stripos( $page['x_robots'], 'noindex' ) ); $duplicates = isset( $page['problems'] ) && array_filter( $page['problems'], function ( $p ) { return 0 === strpos( $p[1], 'duplicate_' ); } ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->insert( Tables::snapshot_items(), array( 'snapshot_id' => $snapshot_id, 'kind' => 2, 'url_hash' => Url::hash( $normalized ), 'url' => mb_substr( $normalized, 0, 500 ), 'object_id' => (int) url_to_postid( $normalized ), 'status' => isset( $page['status'] ) ? (int) $page['status'] : -1, 'noindex' => $noindex ? 1 : 0, 'canonical' => isset( $page['canonicals'][0] ) ? mb_substr( Url::normalize( $page['canonicals'][0], home_url( '/' ) ), 0, 500 ) : '', 'title' => isset( $page['titles'][0] ) ? mb_substr( $page['titles'][0], 0, 255 ) : '', 'duplicates' => $duplicates ? 1 : 0, ) ); } /** * Completes a snapshot and lets the monitor compare it. * * @param int $snapshot_id Snapshot. */ private function finish( $snapshot_id ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->update( Tables::snapshots(), array( 'status' => 'complete', 'completed_at' => current_time( 'mysql', true ), ), array( 'id' => $snapshot_id ) ); $this->prune( (int) $this->settings->get( 'snapshot_retention' ) ); /** * Fires when a snapshot is complete. * * @param int $snapshot_id Snapshot ID. */ do_action( 'orbitstem_seo_snapshot_complete', $snapshot_id ); } /** * Keeps the newest $keep complete snapshots. * * @param int $keep Snapshots to keep. */ public function prune( $keep ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery $old = $wpdb->get_col( $wpdb->prepare( "SELECT id FROM %i WHERE status = 'complete' ORDER BY id DESC LIMIT 1000 OFFSET %d", Tables::snapshots(), max( 2, (int) $keep ) ) ); foreach ( $old as $id ) { $this->delete( (int) $id ); } } /** * Deletes one snapshot and its rows. * * @param int $id Snapshot. */ public function delete( $id ) { global $wpdb; // phpcs:disable WordPress.DB.DirectDatabaseQuery $wpdb->delete( Tables::snapshot_items(), array( 'snapshot_id' => (int) $id ), array( '%d' ) ); $wpdb->delete( Tables::snapshots(), array( 'id' => (int) $id ), array( '%d' ) ); // phpcs:enable } /** * Recent snapshots, newest first. * * @param int $limit Rows. * @return array */ public function all( $limit = 50 ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT s.*, (SELECT COUNT(*) FROM %i i WHERE i.snapshot_id = s.id) AS items FROM %i s ORDER BY s.id DESC LIMIT %d', Tables::snapshot_items(), Tables::snapshots(), (int) $limit ), ARRAY_A ); foreach ( $rows as &$row ) { $row['site'] = json_decode( (string) $row['site'], true ); $row['summary'] = json_decode( (string) $row['summary'], true ); } return $rows; } /** * One snapshot header. * * @param int $id Snapshot. * @return array|null */ public function get( $id ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE id = %d', Tables::snapshots(), (int) $id ), ARRAY_A ); if ( $row ) { $row['site'] = json_decode( (string) $row['site'], true ); $row['summary'] = json_decode( (string) $row['summary'], true ); } return $row ? $row : null; } /** * The complete snapshot before a given one. * * @param int $id Snapshot. * @return int 0 when none. */ public function previous( $id ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery return (int) $wpdb->get_var( $wpdb->prepare( "SELECT id FROM %i WHERE status = 'complete' AND id < %d ORDER BY id DESC LIMIT 1", Tables::snapshots(), (int) $id ) ); } /** * Stores a diff summary on a snapshot. * * @param int $id Snapshot. * @param array $summary Summary. */ public function set_summary( $id, array $summary ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->update( Tables::snapshots(), array( 'summary' => wp_json_encode( $summary ) ), array( 'id' => (int) $id ) ); } }