prefix . 'aieo_sem_related'; $has = function_exists('aieo_dmm_table_exists') ? aieo_dmm_table_exists($table) : ((string) $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)) === $table); if ($has) { $has = (bool) $wpdb->get_var("SELECT 1 FROM `{$table}` LIMIT 1"); } wp_cache_set('aieo_sem_source_available', $has ? 1 : 0, 'aieo_seo', self::AVAILABILITY_TTL); return self::$available = $has; } public static function sanitize_target_types($types) { if (is_string($types)) { $types = explode(',', $types); } $out = array(); foreach ((array) $types as $type) { $type = sanitize_key((string) $type); if ('' !== $type && !in_array($type, $out, true)) { $out[] = $type; } if (count($out) >= 20) { break; } } return $out; } public static function rows_for($source_type, $source_id, $limit = 6, $products_only = true, $target_types = array()) { $source_id = (int) $source_id; $limit = max(1, min(50, (int) $limit)); if ($source_id <= 0 || !self::available()) { return array(); } global $wpdb; $table = $wpdb->prefix . 'aieo_sem_related'; $where = "source_type = %s AND source_id = %d"; $args = array((string) $source_type, $source_id); $types = self::sanitize_target_types($target_types); if ($types) { $where .= ' AND target_type IN (' . implode(', ', array_fill(0, count($types), '%s')) . ')'; $args = array_merge($args, $types); } elseif ($products_only) { $where .= " AND target_type = 'product'"; } $args[] = $limit * 3; $rows = $wpdb->get_results($wpdb->prepare( "SELECT target_type, target_id, score, reason FROM `{$table}` WHERE {$where} ORDER BY `rank` ASC LIMIT %d", $args ), ARRAY_A); if (empty($rows)) { return array(); } $rows = self::apply_display_thresholds($rows); $out = array(); foreach ($rows as $i => $r) { $out[] = array( 'rank' => $i + 1, 'target_type' => isset($r['target_type']) ? (string) $r['target_type'] : 'product', 'target_id' => (int) $r['target_id'], 'score' => (float) $r['score'], 'reason' => isset($r['reason']) ? (string) $r['reason'] : '', ); } $out = self::apply_relation_policies($out, (string) $source_type, $source_id, $limit); foreach ($out as $i => $r) { $out[$i]['rank'] = $i + 1; } return $out; } public static function apply_relation_policies(array $rows, $source_type, $source_id, $limit) { if (empty($rows)) { return $rows; } $aud_on = class_exists('AIEO_Semantic_Audience') && AIEO_Semantic_Audience::enabled(); $time_on = class_exists('AIEO_Semantic_Temporality') && AIEO_Semantic_Temporality::applies_to($source_type); if (!$aud_on && !$time_on) { return $rows; } $limit = max(1, (int) $limit); $source_id = (int) $source_id; $blocked = array(); $defer = array(); $prefer = array(); if ($time_on) { $targets = array(); foreach ($rows as $r) { $targets[] = array((int) $r['target_id'], (string) $r['target_type']); } $part = AIEO_Semantic_Temporality::partition($source_type, $source_id, $targets); $hard = (AIEO_Semantic_Temporality::MODE_EXCLUDE === AIEO_Semantic_Temporality::mode()); foreach ($part['outside'] as $id) { if ($hard) { $blocked[(int) $id] = true; } else { $defer[(int) $id] = true; } } } if ($aud_on) { $ids = array($source_id); foreach ($rows as $r) { $ids[] = (int) $r['target_id']; } $res = AIEO_Semantic_Audience::resolve($ids); $anchor = isset($res[$source_id]) ? $res[$source_id]['audience'] : 'unknown'; $hard = (AIEO_Semantic_Audience::MODE_EXCLUDE === AIEO_Semantic_Audience::mode()); foreach ($rows as $r) { $id = (int) $r['target_id']; $ta = isset($res[$id]) ? $res[$id]['audience'] : 'unknown'; if (!AIEO_Semantic_Audience::allows($anchor, $ta)) { if ($hard) { $blocked[$id] = true; } else { $defer[$id] = true; } } elseif ($ta === $anchor && 'unknown' !== $anchor) { $prefer[$id] = true; } } } if (empty($blocked) && empty($defer) && empty($prefer)) { return $rows; } $head = array(); $mid = array(); $tail = array(); $out = array(); foreach ($rows as $r) { $id = (int) $r['target_id']; if (isset($blocked[$id])) { $out[] = $r; continue; } if (isset($defer[$id])) { $tail[] = $r; } elseif (isset($prefer[$id])) { $head[] = $r; } else { $mid[] = $r; } } $kept = array_merge($head, $mid, $tail); if (count($kept) < $limit && self::never_empty()) { $kept = array_merge($kept, $out); } return $kept; } private static function never_empty() { return class_exists('AIEO_DMM_Settings') ? (bool) AIEO_DMM_Settings::get('semantic.related_never_empty', true) : true; } public static function ids_for($source_id, $limit = 6, $source_type = 'product') { $source_id = (int) $source_id; $limit = max(1, (int) $limit); $rows = self::rows_for($source_type, $source_id, $limit, true); if (empty($rows)) { return array(); } $clone = self::is_clone_set($rows); if (!$clone && class_exists('AIEO_Semantic_Fusion') && method_exists('AIEO_Semantic_Fusion', 'order_related')) { $shaped = array(); foreach ($rows as $r) { $shaped[] = array( 'target_type' => 'product', 'target_id' => (int) $r['target_id'], 'score' => (float) $r['score'], 'reason' => isset($r['reason']) ? (string) $r['reason'] : '', ); } $ordered = AIEO_Semantic_Fusion::order_related($shaped); if (is_array($ordered) && $ordered) { $rows = $ordered; } } $ids = array(); foreach ($rows as $r) { $id = isset($r['target_id']) ? (int) $r['target_id'] : 0; if ($id > 0 && $id !== $source_id) { $ids[$id] = true; } } $ids = array_keys($ids); if (!$clone) { $ids = array_merge($ids, self::pairs_extras($source_id, $ids)); } if (!$clone && 'covis' === self::experiment_arm()) { $ids = self::interleave_covisited($ids, $source_id); } $ids = self::buyable_products($ids); if (empty($ids)) { return array(); } if ($clone) { $ids = self::break_clone_set($ids, $source_id); } $ids = self::apply_relation_policies_ids($ids, $source_type, $source_id, $limit); return self::limit_per_brand($ids, $limit); } private static function apply_relation_policies_ids(array $ids, $source_type, $source_id, $limit) { if (empty($ids)) { return $ids; } $rows = array(); foreach ($ids as $id) { $rows[] = array('target_type' => 'product', 'target_id' => (int) $id); } $rows = self::apply_relation_policies($rows, $source_type, $source_id, $limit); $out = array(); foreach ($rows as $r) { $out[] = (int) $r['target_id']; } return $out; } public static function is_clone_set(array $rows) { if (count($rows) < 6) { return false; } $t = apply_filters('aieo_semantic_related_clone_thresholds', array('top' => 0.95, 'spread' => 0.05)); $top = (float) $rows[0]['score']; if ($top < (float) $t['top']) { return false; } $min = $top; foreach ($rows as $r) { $sc = (float) $r['score']; if ($sc < $min) { $min = $sc; } } return $top > 0 && (($top - $min) / $top) < (float) $t['spread']; } private static function break_clone_set(array $ids, $source_id) { $keep = max(0, (int) apply_filters('aieo_semantic_related_clone_keep', 2)); $head = array_slice($ids, 0, $keep); $covis = self::buyable_products(self::covisited_products($source_id, array_merge(array($source_id), $ids))); $out = array(); foreach (array_merge($head, $covis, array_slice($ids, $keep)) as $id) { $out[(int) $id] = true; } return array_keys($out); } private static function experiment_arm() { static $arm = null; if (null === $arm) { $arm = (class_exists('AIEO_Reco_Experiment') && method_exists('AIEO_Reco_Experiment', 'related_diversity_arm')) ? (string) AIEO_Reco_Experiment::related_diversity_arm() : ''; } return $arm; } private static function interleave_covisited(array $ids, $source_id) { $covis = self::covisited_products($source_id, array_merge(array($source_id), $ids)); if (empty($covis)) { return $ids; } $head = array_slice($ids, 0, 2); $tail = array_slice($ids, 2); $out = array(); foreach ($head as $id) { $out[(int) $id] = true; } $n = max(count($tail), count($covis)); for ($i = 0; $i < $n; $i++) { if (isset($covis[$i])) { $out[(int) $covis[$i]] = true; } if (isset($tail[$i])) { $out[(int) $tail[$i]] = true; } } return array_keys($out); } private static function pairs_extras($source_id, array $exclude) { global $wpdb; static $has_table = null; $table = $wpdb->prefix . 'aieo_sem_pairs'; if (null === $has_table) { $has_table = ((string) $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)) === $table); } if (!$has_table) { return array(); } $rows = $wpdb->get_results($wpdb->prepare( "SELECT CASE WHEN source_id = %d THEN target_id ELSE source_id END AS pid, MAX(score) AS s FROM `{$table}` WHERE source_type = 'product' AND target_type = 'product' AND (source_id = %d OR target_id = %d) GROUP BY pid ORDER BY s DESC LIMIT 60", $source_id, $source_id, $source_id ), ARRAY_A); if (empty($rows)) { return array(); } $skip = array_fill_keys(array_map('intval', $exclude), true); $skip[(int) $source_id] = true; $out = array(); foreach ($rows as $r) { $pid = (int) $r['pid']; if ($pid > 0 && !isset($skip[$pid])) { $out[] = $pid; } } return $out; } private static function covisited_products($source_id, array $exclude) { global $wpdb; static $has = null; $table = $wpdb->prefix . 'aieo_seo_cooccurrence'; if (null === $has) { $has = ((string) $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)) === $table); if ($has) { $has = $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND COLUMN_NAME = 'pair_weight'", $table )) ? 'weight' : 'sessions'; } } if (!$has) { return array(); } $metric = ('weight' === $has) ? 'SUM(c.pair_weight)' : 'SUM(c.co_sessions)'; $rows = $wpdb->get_results($wpdb->prepare( "SELECT CASE WHEN c.a_id = %d THEN c.b_id ELSE c.a_id END AS pid, {$metric} AS w FROM `{$table}` c JOIN {$wpdb->posts} p ON p.ID = CASE WHEN c.a_id = %d THEN c.b_id ELSE c.a_id END AND p.post_type = 'product' AND p.post_status = 'publish' WHERE c.a_id = %d OR c.b_id = %d GROUP BY pid ORDER BY w DESC LIMIT 30", $source_id, $source_id, $source_id, $source_id ), ARRAY_A); if (empty($rows)) { return array(); } $skip = array_fill_keys(array_map('intval', $exclude), true); $out = array(); foreach ($rows as $r) { $pid = (int) $r['pid']; if ($pid > 0 && !isset($skip[$pid])) { $out[] = $pid; } } return $out; } public static function buyable_products(array $ids) { global $wpdb; $ids = array_values(array_filter(array_map('intval', $ids))); if (empty($ids)) { return $ids; } $ph = implode(',', array_fill(0, count($ids), '%d')); $lookup = $wpdb->prefix . 'wc_product_meta_lookup'; static $has_lookup = null; if (null === $has_lookup) { $has_lookup = ((string) $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $lookup)) === $lookup); } $stock_join = $has_lookup ? "LEFT JOIN `{$lookup}` l ON l.product_id = p.ID" : ''; $stock_where = $has_lookup ? "AND (l.product_id IS NULL OR l.stock_status = 'instock')" : ''; $ok = $wpdb->get_col($wpdb->prepare( "SELECT p.ID FROM {$wpdb->posts} p {$stock_join} WHERE p.ID IN ({$ph}) AND p.post_type = 'product' AND p.post_status = 'publish' {$stock_where}", $ids )); $ok = array_fill_keys(array_map('intval', $ok), true); $hidden = $wpdb->get_col($wpdb->prepare( "SELECT tr.object_id FROM {$wpdb->term_relationships} tr JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id JOIN {$wpdb->terms} t ON t.term_id = tt.term_id WHERE tr.object_id IN ({$ph}) AND tt.taxonomy = 'product_visibility' AND t.slug = 'exclude-from-catalog'", $ids )); foreach ($hidden as $h) { unset($ok[(int) $h]); } $out = array(); foreach ($ids as $id) { if (isset($ok[$id])) { $out[] = $id; } } return $out; } public static function diversify_rows(array $rows, $limit) { if (empty($rows)) { return $rows; } $by_id = array(); $ids = array(); foreach ($rows as $r) { $id = isset($r['target_id']) ? (int) $r['target_id'] : 0; if ($id > 0 && !isset($by_id[$id])) { $by_id[$id] = $r; $ids[] = $id; } } $kept = self::limit_per_brand($ids, (int) $limit); $out = array(); foreach ($kept as $id) { if (isset($by_id[$id])) { $out[] = $by_id[$id]; } } return $out ? $out : array_slice($rows, 0, max(1, (int) $limit)); } public static function diversity_rules() { $get = static function ($key, $default) { return class_exists('AIEO_DMM_Settings') ? (int) AIEO_DMM_Settings::get('semantic.' . $key, $default) : $default; }; $brand_cap = $get('related_max_per_brand', 2); $exp_arm = self::experiment_arm(); if ('cap0' === $exp_arm) { $brand_cap = 0; } elseif ('cap2' === $exp_arm || 'covis' === $exp_arm) { $brand_cap = 2; } $rules = array( array('taxonomy' => 'product_brand', 'cap' => $brand_cap), array('taxonomy' => 'product_cat', 'cap' => $get('related_max_per_category', 0)), ); $rules = (array) apply_filters('aieo_semantic_related_diversity_rules', $rules); $out = array(); foreach ($rules as $r) { $tax = isset($r['taxonomy']) ? (string) $r['taxonomy'] : ''; $cap = isset($r['cap']) ? (int) $r['cap'] : 0; if ('' !== $tax && $cap > 0 && taxonomy_exists($tax)) { $out[] = array('taxonomy' => $tax, 'cap' => $cap); } } return $out; } public static function limit_per_brand(array $ids, $limit) { $limit = max(1, (int) $limit); if (empty($ids)) { return $ids; } $rules = self::diversity_rules(); if (empty($rules)) { return array_slice($ids, 0, $limit); } $key_of = array(); foreach ($rules as $i => $rule) { $key_of[$i] = self::diversity_keys($ids, $rule['taxonomy']); } $picked = array(); $held = array(); $counts = array(); foreach ($ids as $id) { $ok = true; $keys = array(); foreach ($rules as $i => $rule) { $k = isset($key_of[$i][$id]) ? (int) $key_of[$i][$id] : 0; $keys[$i] = $k; if (0 === $k) { continue; } $n = isset($counts[$i][$k]) ? $counts[$i][$k] : 0; if ($n >= (int) $rule['cap']) { $ok = false; break; } } if (!$ok) { $held[] = $id; continue; } foreach ($keys as $i => $k) { if ($k > 0) { $counts[$i][$k] = (isset($counts[$i][$k]) ? $counts[$i][$k] : 0) + 1; } } $picked[] = $id; if (count($picked) >= $limit) { break; } } if (count($picked) < $limit && $held) { foreach ($held as $id) { $picked[] = $id; if (count($picked) >= $limit) { break; } } } return $picked; } private static function diversity_keys(array $ids, $taxonomy) { $out = array(); $terms = wp_get_object_terms($ids, $taxonomy, array('fields' => 'all_with_object_id')); if (is_wp_error($terms) || empty($terms)) { return $out; } $best = array(); foreach ($terms as $t) { $oid = (int) $t->object_id; $count = isset($t->count) ? (int) $t->count : PHP_INT_MAX; if (!isset($best[$oid]) || $count < $best[$oid]) { $best[$oid] = $count; $out[$oid] = (int) $t->term_id; } } return $out; } private static function apply_display_thresholds(array $rows) { if (!class_exists('AIEO_DMM_Settings') || empty($rows)) { return $rows; } $min_score = (float) AIEO_DMM_Settings::get('semantic.related_min_score', 0); $min_rel = (float) AIEO_DMM_Settings::get('semantic.related_min_relative', 0); if ($min_score <= 0 && $min_rel <= 0) { return $rows; } $top = (float) $rows[0]['score']; $floor = max( $min_score > 0 ? $min_score : 0.0, ($min_rel > 0 && $top > 0) ? $top * $min_rel : 0.0 ); $kept = array(); foreach ($rows as $i => $r) { if (0 === $i || (float) $r['score'] >= $floor) { $kept[] = $r; } } return $kept; } }