snapshots = $snapshots; } /** * Changes from snapshot A to snapshot B. * * @param int $a Older snapshot. * @param int $b Newer snapshot. * @return array{changes:array,counts:array} */ public function compare( $a, $b ) { $sa = $this->snapshots->get( $a ); $sb = $this->snapshots->get( $b ); if ( ! $sa || ! $sb ) { return array( 'changes' => array(), 'counts' => $this->count( array() ), ); } // The window starts when the older snapshot finished: anything earlier is already in it. $from = $sa['completed_at'] ? $sa['completed_at'] : $sa['created_at']; $to = $sb['completed_at'] ? $sb['completed_at'] : $sb['created_at']; $changes = array_merge( $this->site_changes( (array) $sa['site'], (array) $sb['site'] ), $this->item_changes( $a, $b ), $this->rendered_changes( $a, $b ) ); $changes = $this->explain( $changes, $from, $to ); return array( 'changes' => $changes, 'counts' => $this->count( $changes ), ); } /** * Counts by severity, and unexplained by severity. * * @param array $changes Changes. * @return array */ public function count( array $changes ) { $counts = array( 'error' => 0, 'warning' => 0, 'notice' => 0, 'unexplained' => array( 'error' => 0, 'warning' => 0, 'notice' => 0, ), ); foreach ( $changes as $c ) { ++$counts[ $c['severity'] ]; if ( ! $c['explained'] ) { ++$counts['unexplained'][ $c['severity'] ]; } } return $counts; } /** * Site-wide changes. * * @param array $a Older site signals. * @param array $b Newer site signals. * @return array */ private function site_changes( array $a, array $b ) { $out = array(); $get = function ( $arr, $key, $fallback = '' ) { return isset( $arr[ $key ] ) ? $arr[ $key ] : $fallback; }; if ( 1 === (int) $get( $a, 'blog_public', 1 ) && 0 === (int) $get( $b, 'blog_public', 1 ) ) { $out[] = $this->change( 'site_visibility_off', 'error', 0, '', __( 'Search engines allowed', 'orbitstem-seo' ), __( 'Search engines discouraged (every page now noindex)', 'orbitstem-seo' ) ); } elseif ( 0 === (int) $get( $a, 'blog_public', 1 ) && 1 === (int) $get( $b, 'blog_public', 1 ) ) { $out[] = $this->change( 'site_visibility_on', 'notice', 0, '', __( 'Search engines discouraged', 'orbitstem-seo' ), __( 'Search engines allowed', 'orbitstem-seo' ) ); } if ( $get( $a, 'output_enabled', true ) && ! $get( $b, 'output_enabled', true ) ) { $out[] = $this->change( 'site_output_off', 'warning', 0, '', __( 'Orbitstem SEO tags printed', 'orbitstem-seo' ), __( 'Orbitstem SEO tags switched off (another SEO plugin active?)', 'orbitstem-seo' ) ); } $new_plugins = array_diff( (array) $get( $b, 'seo_plugins', array() ), (array) $get( $a, 'seo_plugins', array() ) ); if ( $new_plugins ) { $out[] = $this->change( 'site_seo_plugin', 'warning', 0, '', '', implode( ', ', $new_plugins ) ); } if ( $get( $a, 'permalink_structure' ) !== $get( $b, 'permalink_structure' ) ) { $out[] = $this->change( 'site_permalinks', 'warning', 0, '', (string) $get( $a, 'permalink_structure' ), (string) $get( $b, 'permalink_structure' ) ); } if ( $get( $a, 'sitemap_enabled', true ) && ! $get( $b, 'sitemap_enabled', true ) ) { $out[] = $this->change( 'site_sitemap_off', 'warning', 0, '', __( 'Sitemap on', 'orbitstem-seo' ), __( 'Sitemap off', 'orbitstem-seo' ) ); } if ( $get( $a, 'theme' ) !== $get( $b, 'theme' ) ) { $out[] = $this->change( 'site_theme', 'notice', 0, '', (string) $get( $a, 'theme' ), (string) $get( $b, 'theme' ) ); } if ( $get( $a, 'settings_hash' ) !== $get( $b, 'settings_hash' ) ) { $out[] = $this->change( 'site_settings', 'notice', 0, '', '', __( 'Orbitstem SEO settings were saved with different values', 'orbitstem-seo' ) ); } if ( '' !== $get( $a, 'robots_txt_hash' ) && $get( $a, 'robots_txt_hash' ) !== $get( $b, 'robots_txt_hash' ) ) { $out[] = $this->change( 'site_robots_txt', 'notice', 0, home_url( '/robots.txt' ), '', __( 'robots.txt content changed', 'orbitstem-seo' ) ); } return $out; } /** * Changes to published items (matched by post ID, so a new slug is a change, not a removal). * * @param int $a Older snapshot. * @param int $b Newer snapshot. * @return array */ private function item_changes( $a, $b ) { global $wpdb; $items = Tables::snapshot_items(); $out = array(); // phpcs:disable WordPress.DB.DirectDatabaseQuery $changed = $wpdb->get_results( $wpdb->prepare( 'SELECT a.object_id, a.url AS a_url, b.url AS b_url, a.noindex AS a_noindex, b.noindex AS b_noindex, a.canonical AS a_canonical, b.canonical AS b_canonical, a.sitemap AS a_sitemap, b.sitemap AS b_sitemap, a.title AS a_title, b.title AS b_title, a.desc_hash AS a_desc, b.desc_hash AS b_desc, a.inbound AS a_inbound, b.inbound AS b_inbound FROM %i a INNER JOIN %i b ON b.snapshot_id = %d AND b.kind = 1 AND b.object_id = a.object_id WHERE a.snapshot_id = %d AND a.kind = 1 AND ( a.url <> b.url OR a.noindex <> b.noindex OR a.canonical <> b.canonical OR a.sitemap <> b.sitemap OR a.title <> b.title OR a.desc_hash <> b.desc_hash OR ( a.inbound > 0 AND b.inbound = 0 ) ) LIMIT %d', $items, $items, $b, $a, self::LIMIT * 4 ), ARRAY_A ); $removed = $wpdb->get_results( $wpdb->prepare( 'SELECT a.object_id, a.url FROM %i a LEFT JOIN %i b ON b.snapshot_id = %d AND b.kind = 1 AND b.object_id = a.object_id WHERE a.snapshot_id = %d AND a.kind = 1 AND b.id IS NULL AND a.noindex = 0 LIMIT %d', $items, $items, $b, $a, self::LIMIT ), ARRAY_A ); $added = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i b LEFT JOIN %i a ON a.snapshot_id = %d AND a.kind = 1 AND a.object_id = b.object_id WHERE b.snapshot_id = %d AND b.kind = 1 AND a.id IS NULL', $items, $items, $a, $b ) ); // phpcs:enable foreach ( $changed as $r ) { $id = (int) $r['object_id']; $url = $r['b_url']; if ( $r['a_url'] !== $r['b_url'] ) { $out[] = $this->change( 'url_changed', 'notice', $id, $url, $r['a_url'], $r['b_url'] ); } if ( ! (int) $r['a_noindex'] && (int) $r['b_noindex'] ) { $out[] = $this->change( 'noindex_on', 'error', $id, $url, 'index', 'noindex' ); } elseif ( (int) $r['a_noindex'] && ! (int) $r['b_noindex'] ) { $out[] = $this->change( 'noindex_off', 'notice', $id, $url, 'noindex', 'index' ); } if ( $r['a_canonical'] !== $r['b_canonical'] && $r['a_url'] === $r['b_url'] ) { $out[] = $this->change( 'canonical_changed', $r['b_canonical'] !== $r['b_url'] ? 'warning' : 'notice', $id, $url, $r['a_canonical'], $r['b_canonical'] ); } if ( (int) $r['a_sitemap'] && ! (int) $r['b_sitemap'] && ! ( ! (int) $r['a_noindex'] && (int) $r['b_noindex'] ) ) { $out[] = $this->change( 'sitemap_out', 'warning', $id, $url, __( 'In sitemap', 'orbitstem-seo' ), __( 'Not in sitemap', 'orbitstem-seo' ) ); } if ( $r['a_title'] !== $r['b_title'] ) { $out[] = $this->change( 'title_changed', 'notice', $id, $url, $r['a_title'], $r['b_title'] ); } if ( $r['a_desc'] !== $r['b_desc'] ) { $out[] = $this->change( 'description_changed', 'notice', $id, $url, '', __( 'Meta description changed', 'orbitstem-seo' ) ); } if ( (int) $r['a_inbound'] > 0 && 0 === (int) $r['b_inbound'] ) { /* translators: %d: number of posts */ $out[] = $this->change( 'inbound_lost', 'notice', $id, $url, sprintf( _n( 'Linked from %d post', 'Linked from %d posts', (int) $r['a_inbound'], 'orbitstem-seo' ), (int) $r['a_inbound'] ), __( 'No longer linked from any post', 'orbitstem-seo' ) ); } } foreach ( $removed as $r ) { $out[] = $this->change( 'removed', 'warning', (int) $r['object_id'], $r['url'], __( 'Published and indexable', 'orbitstem-seo' ), __( 'No longer published (deleted, trashed or unpublished)', 'orbitstem-seo' ) ); } if ( $added ) { /* translators: %d: number of items */ $out[] = $this->change( 'added', 'notice', 0, '', '', sprintf( _n( '%d new published item', '%d new published items', $added, 'orbitstem-seo' ), $added ), true ); } return array_slice( $out, 0, self::LIMIT * 2 ); } /** * Changes in rendered sample pages (matched by URL). * * @param int $a Older snapshot. * @param int $b Newer snapshot. * @return array */ private function rendered_changes( $a, $b ) { global $wpdb; $items = Tables::snapshot_items(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT b.object_id, b.url, a.status AS a_status, b.status AS b_status, a.noindex AS a_noindex, b.noindex AS b_noindex, a.canonical AS a_canonical, b.canonical AS b_canonical, a.title AS a_title, b.title AS b_title, a.duplicates AS a_dup, b.duplicates AS b_dup FROM %i a INNER JOIN %i b ON b.snapshot_id = %d AND b.kind = 2 AND b.url_hash = a.url_hash WHERE a.snapshot_id = %d AND a.kind = 2', $items, $items, $b, $a ), ARRAY_A ); $out = array(); foreach ( $rows as $r ) { $id = (int) $r['object_id']; if ( (int) $r['a_status'] !== (int) $r['b_status'] ) { $out[] = $this->change( 'page_status', 200 === (int) $r['a_status'] ? 'error' : 'warning', $id, $r['url'], 'HTTP ' . $r['a_status'], -1 === (int) $r['b_status'] ? __( 'No response', 'orbitstem-seo' ) : 'HTTP ' . $r['b_status'] ); } if ( ! (int) $r['a_noindex'] && (int) $r['b_noindex'] ) { $out[] = $this->change( 'page_noindex_on', 'error', $id, $r['url'], 'index', 'noindex' ); } // A noindex page prints no canonical: that is part of the noindex change reported above. if ( $r['a_canonical'] !== $r['b_canonical'] && ! ( (int) $r['b_noindex'] && '' === (string) $r['b_canonical'] ) ) { $out[] = $this->change( 'page_canonical', 'warning', $id, $r['url'], $r['a_canonical'], $r['b_canonical'] ); } if ( $r['a_title'] !== $r['b_title'] ) { $out[] = $this->change( 'page_title', 'notice', $id, $r['url'], $r['a_title'], $r['b_title'] ); } if ( ! (int) $r['a_dup'] && (int) $r['b_dup'] ) { $out[] = $this->change( 'page_duplicates', 'warning', $id, $r['url'], '', __( 'Duplicate SEO tags appeared', 'orbitstem-seo' ) ); } } return $out; } /** * Adds explanations from the change log and post edits between the snapshots. * * @param array $changes Changes. * @param string $from Window start (GMT). * @param string $to Window end (GMT). * @return array */ private function explain( array $changes, $from, $to ) { global $wpdb; $by_object = array(); $site = array(); if ( Tables::v3_ready() ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery $log = $wpdb->get_results( $wpdb->prepare( 'SELECT id, fixer, object_type, object_id, summary, user_id, state_after FROM %i WHERE created_at BETWEEN %s AND %s ORDER BY id ASC', Tables::changes(), $from, $to ), ARRAY_A ); foreach ( $log as $entry ) { $after = json_decode( (string) $entry['state_after'], true ); $after = is_array( $after ) ? $after : array(); $kind = isset( $after['kind'] ) ? $after['kind'] : ''; if ( 'post' === $entry['object_type'] ) { $by_object[ (int) $entry['object_id'] ][] = array( 'summary' => $entry['summary'], 'types' => self::explains( $entry['fixer'], $kind, $after ), ); } elseif ( in_array( $kind, array( 'visibility', 'settings' ), true ) ) { $site[ $kind ][] = $entry['summary']; } } } foreach ( $changes as &$c ) { if ( $c['explained'] ) { continue; } $reasons = array(); // Only a recorded change of the matching kind explains it: a canonical // change does not explain a new noindex, for example. foreach ( $c['object_id'] && isset( $by_object[ $c['object_id'] ] ) ? $by_object[ $c['object_id'] ] : array() as $entry ) { if ( in_array( $c['type'], $entry['types'], true ) ) { $reasons[] = $entry['summary']; } } if ( ! $reasons && 'site_visibility_off' === $c['type'] && isset( $site['visibility'] ) ) { $reasons = $site['visibility']; } if ( ! $reasons && in_array( $c['type'], array( 'noindex_on', 'sitemap_out', 'site_settings', 'site_sitemap_off', 'page_noindex_on' ), true ) && isset( $site['settings'] ) ) { $reasons = $site['settings']; } if ( ! $reasons && $c['object_id'] ) { $post = get_post( $c['object_id'] ); // An ordinary edit explains content changes (title, description, address), not // a new noindex, canonical or rendering problem: those settings are recorded // separately when changed in WordPress, so a theme or plugin update stays visible. $edit_explains = in_array( $c['type'], array( 'url_changed', 'title_changed', 'description_changed', 'page_title' ), true ); if ( $edit_explains && $post && $post->post_modified_gmt > $from && $post->post_modified_gmt <= $to ) { $editor = get_userdata( (int) get_post_meta( $post->ID, '_edit_last', true ) ); $reasons[] = sprintf( /* translators: 1: date, 2: user name */ __( 'Post edited on %1$s by %2$s', 'orbitstem-seo' ), get_date_from_gmt( $post->post_modified_gmt, get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ), $editor ? $editor->display_name : __( 'unknown user', 'orbitstem-seo' ) ); } elseif ( ! $post || 'publish' !== $post->post_status ) { if ( 'removed' === $c['type'] && $post ) { /* translators: %s: post status */ $reasons[] = sprintf( __( 'Post status is now "%s"', 'orbitstem-seo' ), $post->post_status ); } } } $c['explained'] = (bool) $reasons; $c['explanation'] = $reasons ? implode( '; ', array_unique( $reasons ) ) : __( 'Not explained by any change recorded here: check recent theme, plugin or server changes.', 'orbitstem-seo' ); } return $changes; } /** * Change types a change-log entry can explain. * * @param string $fixer Fixer id. * @param string $kind Guardian kind ('' for other entries). * @param array $after State after. * @return string[] */ private static function explains( $fixer, $kind, array $after ) { $by_kind = array( 'slug' => array( 'url_changed', 'sitemap_out', 'page_status', 'canonical_changed', 'page_canonical' ), 'post_noindex' => array( 'noindex_on', 'noindex_off', 'page_noindex_on', 'sitemap_out' ), 'post_canonical' => array( 'canonical_changed', 'page_canonical', 'sitemap_out' ), 'trash' => array( 'removed', 'page_status' ), 'freed' => array( 'page_status', 'url_changed' ), ); if ( isset( $by_kind[ $kind ] ) ) { return $by_kind[ $kind ]; } if ( 'csv_import' === $fixer ) { $by_field = array( 'title' => array( 'title_changed', 'page_title' ), 'description' => array( 'description_changed' ), 'noindex' => $by_kind['post_noindex'], 'canonical' => $by_kind['post_canonical'], ); $types = array(); foreach ( array_keys( $after ) as $field ) { if ( isset( $by_field[ $field ] ) ) { $types = array_merge( $types, $by_field[ $field ] ); } } return $types; } // Link repairs and other content edits change text, not indexing. return array( 'title_changed', 'description_changed', 'page_title' ); } /** * One change record. * * @param string $type Type. * @param string $severity Severity. * @param int $object_id Post ID or 0. * @param string $url URL or ''. * @param string $before Before. * @param string $after After. * @param bool $explained Pre-explained (informational). * @return array */ private function change( $type, $severity, $object_id, $url, $before, $after, $explained = false ) { return array( 'type' => $type, 'severity' => $severity, 'object_id' => (int) $object_id, 'url' => (string) $url, 'before' => (string) $before, 'after' => (string) $after, 'explained' => $explained, 'explanation' => $explained ? __( 'New content', 'orbitstem-seo' ) : '', ); } /** * Readable label for a change type. * * @param string $type Type. * @return string */ public static function label( $type ) { $labels = array( 'site_visibility_off' => __( 'Whole site hidden from search engines', 'orbitstem-seo' ), 'site_visibility_on' => __( 'Site visible to search engines again', 'orbitstem-seo' ), 'site_output_off' => __( 'Orbitstem SEO output switched off', 'orbitstem-seo' ), 'site_seo_plugin' => __( 'Another SEO plugin became active', 'orbitstem-seo' ), 'site_permalinks' => __( 'Permalink structure changed', 'orbitstem-seo' ), 'site_sitemap_off' => __( 'Sitemap switched off', 'orbitstem-seo' ), 'site_theme' => __( 'Theme changed', 'orbitstem-seo' ), 'site_settings' => __( 'Orbitstem SEO settings changed', 'orbitstem-seo' ), 'site_robots_txt' => __( 'robots.txt changed', 'orbitstem-seo' ), 'url_changed' => __( 'Address changed', 'orbitstem-seo' ), 'noindex_on' => __( 'Became noindex', 'orbitstem-seo' ), 'noindex_off' => __( 'No longer noindex', 'orbitstem-seo' ), 'canonical_changed' => __( 'Canonical changed', 'orbitstem-seo' ), 'sitemap_out' => __( 'Dropped out of the sitemap', 'orbitstem-seo' ), 'title_changed' => __( 'SEO title changed', 'orbitstem-seo' ), 'description_changed' => __( 'Meta description changed', 'orbitstem-seo' ), 'inbound_lost' => __( 'Lost all internal links', 'orbitstem-seo' ), 'removed' => __( 'No longer published', 'orbitstem-seo' ), 'added' => __( 'New content', 'orbitstem-seo' ), 'page_status' => __( 'Page status changed (rendered)', 'orbitstem-seo' ), 'page_noindex_on' => __( 'Page renders noindex', 'orbitstem-seo' ), 'page_canonical' => __( 'Rendered canonical changed', 'orbitstem-seo' ), 'page_title' => __( 'Rendered title changed', 'orbitstem-seo' ), 'page_duplicates' => __( 'Duplicate tags appeared on page', 'orbitstem-seo' ), ); return isset( $labels[ $type ] ) ? $labels[ $type ] : $type; } }