30, 'search' => 25, 'visitors' => 20, 'fresh' => 20, 'unique' => 15, 'links' => 10, ); const HALF_LIFE = 180; const SCALE = 100; private static $settings = null; private static $columns = null; private static $live = array(); public static function active() { return false !== get_option( self::OPTION, false ); } public static function settings() { if ( null === self::$settings ) { self::$settings = self::sanitize( get_option( self::OPTION, array() ), $ignored, false ); } return self::$settings; } public static function save( $raw, &$errors = null ) { $clean = self::sanitize( $raw, $errors, true ); update_option( self::OPTION, $clean, false ); self::$settings = $clean; return $clean; } public static function reset() { self::$settings = null; self::$columns = null; self::$live = array(); } public static function sanitize( $raw, &$errors = null, $strict = true ) { $errors = array(); $raw = is_array( $raw ) ? $raw : array(); $weights = array(); foreach ( self::SIGNALS as $key => $default ) { $w = $raw['weights'][ $key ] ?? $default; $weights[ $key ] = is_numeric( $w ) ? max( 0, min( 100, (int) round( (float) $w ) ) ) : $default; } $limits = array(); foreach ( array_slice( (array) ( $raw['limits'] ?? array() ), 0, 200, true ) as $target => $limit ) { if ( ! is_string( $target ) || ! preg_match( '/^(post|term):([^\s:<>"\'&]{1,64})$/u', $target, $m ) || ! is_array( $limit ) ) { continue; } if ( $strict && ! ( 'post' === $m[1] ? post_type_exists( $m[2] ) : taxonomy_exists( $m[2] ) ) ) { continue; } $mode = (string) ( $limit['mode'] ?? '' ); if ( ! in_array( $mode, array( 'percent', 'count' ), true ) ) { continue; } $value = class_exists( 'AIEO_SEO_Sitemap_Rules' ) ? AIEO_SEO_Sitemap_Rules::number( is_scalar( $limit['value'] ?? null ) ? (string) $limit['value'] : '' ) : null; $max = 'percent' === $mode ? 100 : 10000000; if ( null === $value || $value < 1 || $value > $max || floor( $value ) != $value ) { $name = ( $strict && class_exists( 'AIEO_SEO_Sitemap_Rules' ) ) ? ( AIEO_SEO_Sitemap_Rules::targets()[ $target ] ?? $target ) : $target; $errors[] = 'percent' === $mode ? sprintf( __( '%s: the share to keep is a whole number from 1 to 100.', 'ai-eshop-optimizer' ), $name ) : sprintf( __( '%s: the number to keep is a whole number from 1.', 'ai-eshop-optimizer' ), $name ); continue; } $limits[ $target ] = array( 'mode' => $mode, 'value' => (int) $value, ); } if ( $limits && ! array_sum( $weights ) ) { $errors[] = __( 'Give at least one signal a weight: a limit keeps the best-scored items.', 'ai-eshop-optimizer' ); } return array( 'weights' => $weights, 'limits' => $limits, ); } public static function columns_ok() { if ( null === self::$columns ) { global $wpdb; $t = $wpdb->prefix . 'aieo_seo_posts'; $prev = $wpdb->suppress_errors( true ); self::$columns = 2 === (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND COLUMN_NAME IN ('priority_score', 'sitemap_cut')", $t ) ); $wpdb->suppress_errors( $prev ); } return self::$columns; } public static function types() { global $wpdb; $t = $wpdb->prefix . 'aieo_seo_posts'; $out = array(); foreach ( (array) $wpdb->get_results( "SELECT object_type, COUNT(*) AS n FROM `{$t}` WHERE indexable = 1 AND canonical_url <> '' GROUP BY object_type ORDER BY object_type", ARRAY_A ) as $r ) { $out[ (string) $r['object_type'] ] = (int) $r['n']; } return $out; } public static function run_all() { if ( ! self::columns_ok() ) { return array(); } if ( ! self::active() ) { self::clear_cuts(); return array(); } $out = array(); foreach ( array_keys( self::types() ) as $type ) { $out[ $type ] = self::run_type( $type ); } self::clear_cuts( array_keys( $out ) ); return $out; } public static function run_type( $type ) { if ( ! self::columns_ok() ) { return array( 'scored' => 0, 'listed' => 0, 'cut' => 0 ); } $scored = self::score_type( (string) $type ); $cut = self::cut_type( (string) $type ); return array( 'scored' => $scored ) + $cut; } public static function clear_cuts( array $except = array() ) { global $wpdb; $t = $wpdb->prefix . 'aieo_seo_posts'; if ( ! self::columns_ok() ) { return; } $not = $except ? ' AND object_type NOT IN (' . implode( ',', array_fill( 0, count( $except ), '%s' ) ) . ')' : ''; $sql = "UPDATE `{$t}` SET sitemap_cut = 0 WHERE sitemap_cut <> 0{$not}"; $wpdb->query( $except ? $wpdb->prepare( $sql, $except ) : $sql ); } public static function score_type( $type ) { global $wpdb; $t = $wpdb->prefix . 'aieo_seo_posts'; $rows = array(); foreach ( (array) $wpdb->get_results( $wpdb->prepare( "SELECT id, object_id FROM `{$t}` WHERE object_type = %s AND indexable = 1 AND canonical_url <> ''", $type ), ARRAY_A ) as $r ) { $rows[ (int) $r['object_id'] ] = (int) $r['id']; } $wpdb->flush(); if ( ! $rows ) { return 0; } $kind = class_exists( 'AIEO_SEO_Sitemap_Rules' ) ? AIEO_SEO_Sitemap_Rules::kind( $type ) : ''; $weights = self::settings()['weights']; $signals = self::signals( $type, $kind, array_keys( $rows ) ); $scores = array(); foreach ( $rows as $object_id => $row_id ) { $sum = 0.0; $total = 0; foreach ( $signals as $key => $values ) { if ( empty( $weights[ $key ] ) || ! isset( $values[ $object_id ] ) ) { continue; } $sum += $weights[ $key ] * $values[ $object_id ]; $total += $weights[ $key ]; } $scores[ $row_id ] = $total ? (int) round( $sum / $total * self::SCALE ) : 0; } self::write_scores( $scores ); return count( $scores ); } public static function cut_type( $type ) { global $wpdb; $t = $wpdb->prefix . 'aieo_seo_posts'; $kind = class_exists( 'AIEO_SEO_Sitemap_Rules' ) ? AIEO_SEO_Sitemap_Rules::kind( $type ) : ''; $limit = self::settings()['limits'][ $kind . ':' . $type ] ?? null; $rows = (array) $wpdb->get_results( $wpdb->prepare( "SELECT id, sitemap FROM `{$t}` WHERE object_type = %s AND indexable = 1 AND canonical_url <> '' AND sitemap > 0 ORDER BY priority_score DESC, id DESC", $type ), ARRAY_A ); $wpdb->flush(); $n = count( $rows ); $keep = $n; if ( is_array( $limit ) ) { $keep = 'percent' === $limit['mode'] ? (int) ceil( $n * $limit['value'] / 100 ) : min( $n, (int) $limit['value'] ); } $always = 0; foreach ( $rows as $r ) { $always += 2 === (int) $r['sitemap'] ? 1 : 0; } $room = max( 0, $keep - $always ); $cut = array(); foreach ( $rows as $r ) { if ( 2 === (int) $r['sitemap'] ) { continue; } if ( $room > 0 ) { --$room; continue; } $cut[] = (int) $r['id']; } $was = array_map( 'intval', (array) $wpdb->get_col( $wpdb->prepare( "SELECT id FROM `{$t}` WHERE object_type = %s AND sitemap_cut = 1", $type ) ) ); $was = array_flip( $was ); $now = array_flip( $cut ); $set = array_keys( array_diff_key( $now, $was ) ); $clear = array_keys( array_diff_key( $was, $now ) ); foreach ( array( 1 => $set, 0 => $clear ) as $flag => $ids ) { foreach ( array_chunk( $ids, 1000 ) as $chunk ) { $wpdb->query( "UPDATE `{$t}` SET sitemap_cut = " . (int) $flag . ' WHERE id IN (' . implode( ',', array_map( 'intval', $chunk ) ) . ')' ); } } return array( 'listed' => $n - count( $cut ), 'cut' => count( $cut ), ); } private static function write_scores( array $scores ) { global $wpdb; $t = $wpdb->prefix . 'aieo_seo_posts'; $tmp = $wpdb->prefix . 'aieo_sitemap_scores_tmp'; $prev = $wpdb->suppress_errors( true ); $made = false !== $wpdb->query( "CREATE TEMPORARY TABLE IF NOT EXISTS `{$tmp}` ( id BIGINT UNSIGNED NOT NULL PRIMARY KEY, s SMALLINT UNSIGNED NOT NULL )" ); $whole = $made && false !== $wpdb->query( "DELETE FROM `{$tmp}`" ); foreach ( $whole ? array_chunk( $scores, 1000, true ) : array() as $chunk ) { $values = array(); foreach ( $chunk as $id => $s ) { $values[] = '(' . (int) $id . ',' . max( 0, min( 65535, (int) $s ) ) . ')'; } if ( false === $wpdb->query( "INSERT INTO `{$tmp}` (id, s) VALUES " . implode( ',', $values ) ) ) { $whole = false; break; } } $whole = $whole && false !== $wpdb->query( "UPDATE `{$t}` sp JOIN `{$tmp}` x ON x.id = sp.id SET sp.priority_score = x.s WHERE sp.priority_score <> x.s" ); if ( $made ) { $wpdb->query( "DROP TEMPORARY TABLE IF EXISTS `{$tmp}`" ); } $wpdb->suppress_errors( $prev ); if ( $whole ) { return; } foreach ( array_chunk( $scores, 500, true ) as $chunk ) { $case = ''; foreach ( $chunk as $id => $s ) { $case .= ' WHEN ' . (int) $id . ' THEN ' . max( 0, min( 65535, (int) $s ) ); } $wpdb->query( "UPDATE `{$t}` SET priority_score = CASE id{$case} END WHERE id IN (" . implode( ',', array_map( 'intval', array_keys( $chunk ) ) ) . ')' ); } } public static function signals( $type, $kind, array $ids ) { $want = array_flip( array_map( 'intval', $ids ) ); $out = array(); foreach ( array_keys( self::SIGNALS ) as $key ) { $raw = self::raw( $key, $type, $kind ); if ( null === $raw ) { continue; } list( $values, $missing_is_zero ) = $raw; $values = array_intersect_key( $values, $want ); if ( $missing_is_zero ) { $values += array_fill_keys( array_keys( array_diff_key( $want, $values ) ), 0.0 ); } if ( ! $values ) { continue; } if ( 'fresh' === $key ) { $out[ $key ] = $values; } elseif ( 'unique' === $key ) { $out[ $key ] = array_map( static fn ( $p ) => 100.0 - $p, self::percentiles( $values ) ); } else { $out[ $key ] = self::percentiles( $values ); } } return $out; } private static function raw( $key, $type, $kind ) { global $wpdb; $values = array(); switch ( $key ) { case 'sales': if ( 'product' === $type ) { $t = $wpdb->prefix . 'aieo_seo_posts'; $values = self::pairs( "SELECT object_id AS k, demand_score AS v FROM `{$t}` WHERE object_type = 'product' AND demand_score IS NOT NULL" ); return $values ? array( $values, false ) : null; } $brand = class_exists( 'AIEO_Order_Rules' ) && method_exists( 'AIEO_Order_Rules', 'brand_tax' ) ? (string) AIEO_Order_Rules::brand_tax() : 'product_brand'; $p = $wpdb->prefix . 'aieo_core_category_brand_popularity'; if ( 'term' !== $kind || ! in_array( $type, array( 'product_cat', $brand ), true ) || ! self::has_rows( $p ) ) { return null; } $values = 'product_cat' === $type ? self::pairs( "SELECT category_id AS k, SUM(total_turnover) AS v FROM `{$p}` WHERE brand_id IS NULL AND category_id IS NOT NULL GROUP BY category_id" ) : self::pairs( "SELECT brand_id AS k, SUM(total_turnover) AS v FROM `{$p}` WHERE brand_id IS NOT NULL AND category_id IS NULL GROUP BY brand_id" ); return array( $values, true ); case 'search': $g = $wpdb->prefix . 'aieo_gsc_daily'; if ( self::live( 'gsc', static fn () => self::has_rows( $g, "dim_type = 'page'" ) ) ) { $cutoff = gmdate( 'Y-m-d', time() - 90 * DAY_IN_SECONDS ); $values = self::pairs( $wpdb->prepare( "SELECT object_id AS k, SUM(clicks) + 0.02 * SUM(impressions) AS v FROM `{$g}` WHERE dim_type = 'page' AND object_type = %s AND object_id > 0 AND `date` >= %s GROUP BY object_id", $type, $cutoff ) ); return array( $values, true ); } $t = $wpdb->prefix . 'aieo_seo_posts'; if ( self::live( 'dfs', static fn () => self::has_rows( $t, 'search_demand_score > 0' ) ) ) { $values = self::pairs( $wpdb->prepare( "SELECT object_id AS k, search_demand_score AS v FROM `{$t}` WHERE object_type = %s", $type ) ); return array( $values, true ); } return null; case 'visitors': if ( 'post' !== $kind ) { return null; } if ( 'product' === $type ) { $l = $wpdb->prefix . 'aieo_product_lifetime'; if ( ! self::live( 'lifetime', static fn () => self::has_rows( $l ) ) ) { return null; } return array( self::pairs( "SELECT product_id AS k, product_views_30d AS v FROM `{$l}`" ), true ); } $c = $wpdb->prefix . 'aieo_core_content_vitals'; if ( ! self::live( 'content', static fn () => self::has_rows( $c ) ) ) { return null; } return array( self::pairs( $wpdb->prepare( "SELECT content_id AS k, visitors_30d AS v FROM `{$c}` WHERE post_type = %s", $type ) ), true ); case 'fresh': if ( 'post' !== $kind ) { return null; } $t = $wpdb->prefix . 'aieo_seo_posts'; $now = time(); foreach ( (array) $wpdb->get_results( $wpdb->prepare( "SELECT p.ID AS k, p.post_modified_gmt AS m FROM `{$wpdb->posts}` p JOIN `{$t}` sp ON sp.object_id = p.ID AND sp.object_type = %s WHERE sp.indexable = 1", $type ), ARRAY_A ) as $r ) { $ts = '0000-00-00 00:00:00' === $r['m'] ? 0 : (int) strtotime( $r['m'] . ' UTC' ); if ( $ts > 0 ) { $days = max( 0, ( $now - $ts ) / DAY_IN_SECONDS ); $values[ (int) $r['k'] ] = 100 * pow( 0.5, $days / self::HALF_LIFE ); } } return $values ? array( $values, false ) : null; case 'unique': $s = $wpdb->prefix . 'aieo_sem_pairs'; if ( ! self::live( 'pairs', static fn () => self::has_rows( $s ) ) ) { return null; } $values = self::pairs( $wpdb->prepare( "SELECT source_id AS k, MAX(score) AS v FROM `{$s}` WHERE source_type = %s GROUP BY source_id", $type ) ); return $values ? array( $values, false ) : null; case 'links': $k = $wpdb->prefix . 'aieo_sem_links'; if ( ! self::live( 'links', static fn () => self::has_rows( $k, "status = 'approved'" ) ) ) { return null; } return array( self::pairs( $wpdb->prepare( "SELECT target_id AS k, COUNT(*) AS v FROM `{$k}` WHERE status = 'approved' AND target_type = %s GROUP BY target_id", $type ) ), true ); } return null; } public static function available() { global $wpdb; $p = $wpdb->prefix; return array( 'sales' => self::has_rows( $p . 'aieo_seo_posts', "object_type = 'product' AND demand_score IS NOT NULL" ) || self::has_rows( $p . 'aieo_core_category_brand_popularity' ), 'search' => self::has_rows( $p . 'aieo_gsc_daily', "dim_type = 'page'" ) || self::has_rows( $p . 'aieo_seo_posts', 'search_demand_score > 0' ), 'visitors' => self::has_rows( $p . 'aieo_core_content_vitals' ) || self::has_rows( $p . 'aieo_product_lifetime' ), 'fresh' => true, 'unique' => self::has_rows( $p . 'aieo_sem_pairs' ), 'links' => self::has_rows( $p . 'aieo_sem_links', "status = 'approved'" ), ); } public static function percentiles( array $values ) { $n = count( $values ); if ( $n < 2 ) { return array_map( static fn () => 50.0, $values ); } asort( $values, SORT_NUMERIC ); $keys = array_keys( $values ); $vals = array_values( $values ); $out = array(); for ( $i = 0; $i < $n; $i = $j + 1 ) { $j = $i; while ( $j + 1 < $n && (float) $vals[ $j + 1 ] === (float) $vals[ $i ] ) { ++$j; } $p = (float) ( ( $i + $j ) / 2 / ( $n - 1 ) * 100 ); for ( $k = $i; $k <= $j; $k++ ) { $out[ $keys[ $k ] ] = $p; } } return $out; } private static function pairs( $sql ) { global $wpdb; $out = array(); foreach ( (array) $wpdb->get_results( $sql, ARRAY_A ) as $r ) { $out[ (int) $r['k'] ] = (float) $r['v']; } $wpdb->flush(); return $out; } private static function has_rows( $table, $where = '1=1' ) { global $wpdb; $prev = $wpdb->suppress_errors( true ); $hit = $table === $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ) && null !== $wpdb->get_var( "SELECT 1 FROM `{$table}` WHERE {$where} LIMIT 1" ); $wpdb->suppress_errors( $prev ); return $hit; } private static function live( $name, callable $probe ) { if ( ! array_key_exists( $name, self::$live ) ) { self::$live[ $name ] = (bool) $probe(); } return self::$live[ $name ]; } public static function labels() { return array( 'sales' => __( 'Sales performance', 'ai-eshop-optimizer' ), 'search' => __( 'Search demand', 'ai-eshop-optimizer' ), 'visitors' => __( 'Visitors', 'ai-eshop-optimizer' ), 'fresh' => __( 'Content freshness', 'ai-eshop-optimizer' ), 'unique' => __( 'Content uniqueness', 'ai-eshop-optimizer' ), 'links' => __( 'Internal link importance', 'ai-eshop-optimizer' ), ); } }