|
|
// utils/recommendation.js
|
|
|
// 行为权重与推荐算法(本地轻量级实时推荐)
|
|
|
const DEFAULT_WEIGHTS = {
|
|
|
categories: {},
|
|
|
keywords: {},
|
|
|
lastUpdated: null
|
|
|
};
|
|
|
|
|
|
function getStoreKey() {
|
|
|
return 'behaviorWeights';
|
|
|
}
|
|
|
|
|
|
function loadWeights() {
|
|
|
try {
|
|
|
const w = wx.getStorageSync(getStoreKey());
|
|
|
if (w && typeof w === 'object') return w;
|
|
|
} catch (e) {}
|
|
|
return { ...DEFAULT_WEIGHTS };
|
|
|
}
|
|
|
|
|
|
function saveWeights(w) {
|
|
|
try {
|
|
|
w.lastUpdated = Date.now();
|
|
|
wx.setStorageSync(getStoreKey(), w);
|
|
|
} catch (e) {}
|
|
|
}
|
|
|
|
|
|
function inc(map, key, delta) {
|
|
|
if (!key) return;
|
|
|
const k = String(key).trim();
|
|
|
if (!k) return;
|
|
|
map[k] = (map[k] || 0) + (delta || 1);
|
|
|
if (map[k] < 0) map[k] = 0; // 不允许为负,便于稳定排序
|
|
|
}
|
|
|
|
|
|
function tokenize(text) {
|
|
|
if (!text || typeof text !== 'string') return [];
|
|
|
const t = text.toLowerCase();
|
|
|
// 基础分词:空白/标点分割,保留中文词串
|
|
|
const rough = t.split(/[\s,;,。.!!??\-_/]+/).filter(Boolean);
|
|
|
// 去重并限制长度
|
|
|
const seen = new Set();
|
|
|
const out = [];
|
|
|
for (let i = 0; i < rough.length && out.length < 20; i++) {
|
|
|
const token = rough[i].trim();
|
|
|
if (!token) continue;
|
|
|
if (!seen.has(token)) {
|
|
|
seen.add(token);
|
|
|
out.push(token);
|
|
|
}
|
|
|
}
|
|
|
return out;
|
|
|
}
|
|
|
|
|
|
// 记录:注册兴趣 -> 类别权重
|
|
|
function recordInterests(interests, weight = 5) {
|
|
|
const w = loadWeights();
|
|
|
(interests || []).forEach(cat => inc(w.categories, cat, weight));
|
|
|
saveWeights(w);
|
|
|
}
|
|
|
|
|
|
// 记录:搜索关键词(商品/求购)
|
|
|
function recordSearch(keyword, type = 'product') {
|
|
|
const w = loadWeights();
|
|
|
const base = type === 'wanted' ? 2 : 3; // 商品搜索更强一点
|
|
|
tokenize(keyword).forEach(k => inc(w.keywords, k, base));
|
|
|
saveWeights(w);
|
|
|
}
|
|
|
|
|
|
// 记录:浏览商品详情(类别 + 关键词)
|
|
|
function recordView(product) {
|
|
|
const w = loadWeights();
|
|
|
inc(w.categories, product?.productCategory || product?.category || '其他', 1);
|
|
|
const nameTokens = tokenize(product?.productName || '');
|
|
|
const descTokens = tokenize(product?.productDescription || product?.description || '');
|
|
|
[...nameTokens, ...descTokens].slice(0, 15).forEach(k => inc(w.keywords, k, 1));
|
|
|
saveWeights(w);
|
|
|
}
|
|
|
|
|
|
// 记录:点击商品卡片(比浏览稍高)
|
|
|
function recordClick(product) {
|
|
|
const w = loadWeights();
|
|
|
inc(w.categories, product?.productCategory || product?.category || '其他', 2);
|
|
|
const nameTokens = tokenize(product?.productName || '');
|
|
|
const descTokens = tokenize(product?.productDescription || product?.description || '');
|
|
|
[...nameTokens, ...descTokens].slice(0, 15).forEach(k => inc(w.keywords, k, 2));
|
|
|
saveWeights(w);
|
|
|
try {
|
|
|
const cat = product?.productCategory || product?.category || '其他';
|
|
|
const pid = product?._id || product?.id || '';
|
|
|
wx.setStorageSync('lastInteraction', { category: cat, productId: pid, ts: Date.now() });
|
|
|
} catch (e) {}
|
|
|
}
|
|
|
|
|
|
// 记录:收藏商品(比点击更高,表示强偏好)
|
|
|
function recordFavorite(product) {
|
|
|
const w = loadWeights();
|
|
|
inc(w.categories, product?.productCategory || product?.category || '其他', 4);
|
|
|
const nameTokens = tokenize(product?.productName || product?.name || '');
|
|
|
const descTokens = tokenize(product?.productDescription || product?.description || '');
|
|
|
[...nameTokens, ...descTokens].slice(0, 20).forEach(k => inc(w.keywords, k, 4));
|
|
|
saveWeights(w);
|
|
|
try {
|
|
|
const cat = product?.productCategory || product?.category || '其他';
|
|
|
const pid = product?._id || product?.id || '';
|
|
|
wx.setStorageSync('lastInteraction', { category: cat, productId: pid, ts: Date.now() });
|
|
|
} catch (e) {}
|
|
|
}
|
|
|
|
|
|
// 记录:取消收藏(轻度降低偏好)
|
|
|
function recordUnfavorite(product) {
|
|
|
const w = loadWeights();
|
|
|
inc(w.categories, product?.productCategory || product?.category || '其他', -2);
|
|
|
const nameTokens = tokenize(product?.productName || product?.name || '');
|
|
|
const descTokens = tokenize(product?.productDescription || product?.description || '');
|
|
|
[...nameTokens, ...descTokens].slice(0, 20).forEach(k => inc(w.keywords, k, -2));
|
|
|
saveWeights(w);
|
|
|
}
|
|
|
|
|
|
// 记录:发布求购(类别 + 关键词权重更高)
|
|
|
function recordPublishWanted(category, productName, description) {
|
|
|
const w = loadWeights();
|
|
|
inc(w.categories, category || '其他', 4);
|
|
|
const tokens = [...tokenize(productName), ...tokenize(description)].slice(0, 20);
|
|
|
tokens.forEach(k => inc(w.keywords, k, 4));
|
|
|
saveWeights(w);
|
|
|
}
|
|
|
|
|
|
function getWeights() {
|
|
|
return loadWeights();
|
|
|
}
|
|
|
|
|
|
function scoreProduct(product, weights) {
|
|
|
const cat = product?.productCategory || product?.category || '其他';
|
|
|
const catWeight = (weights.categories[cat] || 0) * 1; // 类别次要
|
|
|
const nameTokens = tokenize(product?.productName || product?.name || '');
|
|
|
const descTokens = tokenize(product?.productDescription || product?.description || '');
|
|
|
const kw = weights.keywords || {};
|
|
|
// 名称更重要,描述次之
|
|
|
const nameScore = nameTokens.reduce((s, k) => s + (kw[k] || 0), 0);
|
|
|
const descScore = descTokens.reduce((s, k) => s + Math.min((kw[k] || 0), 3), 0);
|
|
|
const kwScore = nameScore * 2 + descScore * 1;
|
|
|
// 轻度时间加成:越新越靠前
|
|
|
let timeBoost = 0;
|
|
|
const ct = product?.createTime ? new Date(product.createTime).getTime() : 0;
|
|
|
if (ct) {
|
|
|
const now = Date.now();
|
|
|
const days = (now - ct) / (24 * 3600 * 1000);
|
|
|
timeBoost = Math.max(0, 3 - Math.min(days, 7)); // 7天后加成耗尽
|
|
|
}
|
|
|
// 人气加成:浏览量等(仿电商平台,影响较轻)
|
|
|
const vc = Number(product?.viewCount || 0);
|
|
|
const popularity = Math.log(1 + Math.max(0, vc)) * 0.5; // 0~约2
|
|
|
return catWeight + kwScore + timeBoost + popularity;
|
|
|
}
|
|
|
|
|
|
function reorderProductsByWeights(products) {
|
|
|
const weights = getWeights();
|
|
|
if (!products || products.length === 0) return [];
|
|
|
const withScore = products.map(p => ({ p, s: scoreProduct(p, weights) }));
|
|
|
withScore.sort((a, b) => b.s - a.s);
|
|
|
return withScore.map(x => x.p);
|
|
|
}
|
|
|
|
|
|
module.exports = {
|
|
|
recordInterests,
|
|
|
recordSearch,
|
|
|
recordView,
|
|
|
recordClick,
|
|
|
recordFavorite,
|
|
|
recordUnfavorite,
|
|
|
recordPublishWanted,
|
|
|
getWeights,
|
|
|
scoreProduct,
|
|
|
reorderProductsByWeights,
|
|
|
tokenize
|
|
|
}; |