// VOC 数据分析模块 · v2(增强版)
const esc = (s) => String(s == null ? '' : s)
.replace(/&/g, '&').replace(//g, '>')
.replace(/"/g, '"').replace(/'/g, ''');
const fmtNum = (n) => {
if (n == null || isNaN(n)) return '—';
if (n >= 1e8) return (n / 1e8).toFixed(1) + '亿';
if (n >= 1e4) return (n / 1e4).toFixed(1) + 'w';
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
return String(n);
};
const fmtMoney = (cents) => {
const dollars = (cents || 0) / 100;
if (dollars >= 1e6) return '$' + (dollars / 1e6).toFixed(1) + 'M';
if (dollars >= 1e3) return '$' + (dollars / 1e3).toFixed(1) + 'k';
return '$' + dollars.toFixed(2);
};
const cleanText = (s) => String(s || '')
.replace(/\[[^\]]{1,6}R\]/g, '').replace(/\s+/g, ' ').trim();
const median = (arr) => {
if (!arr.length) return 0;
const sorted = arr.slice().sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
};
// ============================================================
// XHS 分析
// ============================================================
function analyzeXhs(voc) {
const allNotes = [], allComments = [], allUsers = {};
for (const kw of Object.keys(voc.xiaohongshu?.by_keyword || {})) {
const d = voc.xiaohongshu.by_keyword[kw];
(d.notes || []).forEach((n) => allNotes.push({ ...n, _keyword: kw }));
for (const nid of Object.keys(d.comments || {})) {
(d.comments[nid] || []).forEach((c) => allComments.push({ ...c, _note_id: nid, _keyword: kw }));
}
for (const uid of Object.keys(d.users || {})) {
if (!allUsers[uid]) allUsers[uid] = d.users[uid];
}
}
const noteMap = new Map();
allNotes.forEach((n) => { if (n.id && !noteMap.has(n.id)) noteMap.set(n.id, n); });
const notes = Array.from(noteMap.values());
const commentMap = new Map();
allComments.forEach((c) => { if (c.id && !commentMap.has(c.id)) commentMap.set(c.id, c); });
const comments = Array.from(commentMap.values());
const metrics = {
notes: notes.length,
totalLikes: notes.reduce((s, n) => s + (n.liked_count || 0), 0),
totalComments: notes.reduce((s, n) => s + (n.comments_count || 0), 0),
totalCollected: notes.reduce((s, n) => s + (n.collected_count || 0), 0),
totalShared: notes.reduce((s, n) => s + (n.shared_count || 0), 0),
sampleComments: comments.length,
};
const topNotes = notes.slice().sort((a, b) => (b.liked_count || 0) - (a.liked_count || 0)).slice(0, 12);
const topComments = comments.filter((c) => c.content && c.content.length >= 8 && c.content.length <= 300)
.slice().sort((a, b) => (b.like_count || 0) - (a.like_count || 0)).slice(0, 20);
const allKols = Object.values(allUsers).filter((u) => u && u.nickname).map((u) => {
const fans = u.fans || (u.interactions || []).find((x) => x.type === 'fans')?.count || 0;
const interaction = (u.interactions || []).find((x) => x.type === 'interaction')?.count || 0;
const likes = (u.interactions || []).find((x) => x.type === 'likes')?.count || 0;
return { ...u, fans, interaction, likes };
}).sort((a, b) => b.fans - a.fans);
// KOL 过滤:≥1000 粉丝才算真实作者
const MIN_FANS = 1000;
const kols = allKols.filter((k) => k.fans >= MIN_FANS);
const kolTier = {
head: kols.filter((k) => k.fans >= 500000), // 头部 50w+
mid: kols.filter((k) => k.fans >= 50000 && k.fans < 500000), // 腰部 5w-50w
tail: kols.filter((k) => k.fans >= 5000 && k.fans < 50000), // 尾部 5k-5w
koc: kols.filter((k) => k.fans >= MIN_FANS && k.fans < 5000), // KOC 1k-5k
};
metrics.kols = kols.length;
metrics.kolsTotal = allKols.length;
metrics.kolsFiltered = allKols.length - kols.length;
const ipDist = {};
comments.forEach((c) => { const ip = c.ip_location; if (ip) ipDist[ip] = (ipDist[ip] || 0) + 1; });
const topIPs = Object.entries(ipDist).sort((a, b) => b[1] - a[1]).slice(0, 10);
return { notes, comments, metrics, topNotes, topComments, kols, allKols, kolTier, topIPs };
}
// ============================================================
// Amazon 分析(丰富维度)
// ============================================================
function analyzeAmazon(voc) {
const amz = voc.amazon || voc.sorftime; // 兼容旧格式
if (!amz || !amz.by_keyword) return { products: [], empty: true };
// 合并多关键词的产品池(去重 by ASIN)
let merged = amz.merged_products || [];
if (!merged.length) {
// 从 by_keyword 合并
const asinMap = new Map();
for (const kw of Object.keys(amz.by_keyword)) {
const ps = amz.by_keyword[kw]?.top_products?.Products
|| amz.by_keyword[kw]?.top_products?.Data?.Products
|| [];
for (const p of ps) {
if (!p.Asin) continue;
const cur = asinMap.get(p.Asin);
if (!cur || (p.ListingSalesVolumeOfMonth || 0) > (cur.ListingSalesVolumeOfMonth || 0)) {
asinMap.set(p.Asin, { ...p, _from_keyword: kw });
}
}
}
merged = Array.from(asinMap.values())
.sort((a, b) => (b.ListingSalesVolumeOfMonth || 0) - (a.ListingSalesVolumeOfMonth || 0));
}
if (!merged.length) return { products: [], empty: true };
const details = amz.product_details || {};
const reviewsByAsin = amz.reviews_by_asin || {};
// 价格统计 (分 → 美元)
const prices = merged.map((p) => (p.SalesPrice || p.Price || 0) / 100).filter((x) => x > 0);
const priceStats = {
count: prices.length,
min: prices.length ? Math.min(...prices) : 0,
max: prices.length ? Math.max(...prices) : 0,
mean: prices.length ? prices.reduce((s, x) => s + x, 0) / prices.length : 0,
median: median(prices),
};
// 价格带分布
const priceBuckets = { '<$15': 0, '$15-25': 0, '$25-40': 0, '$40-60': 0, '$60-100': 0, '>$100': 0 };
merged.forEach((p) => {
const price = (p.SalesPrice || p.Price || 0) / 100;
if (!price) return;
if (price < 15) priceBuckets['<$15']++;
else if (price < 25) priceBuckets['$15-25']++;
else if (price < 40) priceBuckets['$25-40']++;
else if (price < 60) priceBuckets['$40-60']++;
else if (price < 100) priceBuckets['$60-100']++;
else priceBuckets['>$100']++;
});
// 品牌份额(按月销量)
const brandSales = {};
let totalSales = 0;
merged.forEach((p) => {
if (!p.Brand) return;
const sales = p.ListingSalesOfMonth || 0; // 月销售额(分)
brandSales[p.Brand] = (brandSales[p.Brand] || 0) + sales;
totalSales += sales;
});
const topBrands = Object.entries(brandSales)
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([brand, sales]) => ({
brand,
sales,
share: totalSales > 0 ? (sales / totalSales * 100) : 0,
count: merged.filter((p) => p.Brand === brand).length,
}));
// 市场集中度
const top5Share = topBrands.slice(0, 5).reduce((s, b) => s + b.share, 0);
const hhi = topBrands.reduce((s, b) => s + Math.pow(b.share, 2), 0);
// 评分分布
const ratingBuckets = { '< 4.0': 0, '4.0-4.3': 0, '4.3-4.5': 0, '4.5-4.7': 0, '≥ 4.7': 0 };
const ratings = [];
merged.forEach((p) => {
const r = parseFloat(p.Ratings || 0);
if (!r) return;
ratings.push(r);
if (r < 4.0) ratingBuckets['< 4.0']++;
else if (r < 4.3) ratingBuckets['4.0-4.3']++;
else if (r < 4.5) ratingBuckets['4.3-4.5']++;
else if (r < 4.7) ratingBuckets['4.5-4.7']++;
else ratingBuckets['≥ 4.7']++;
});
const avgRating = ratings.length ? ratings.reduce((s, x) => s + x, 0) / ratings.length : 0;
const medianRating = median(ratings);
// 评论数分布
const reviewCountBuckets = { '< 100': 0, '100-500': 0, '500-1k': 0, '1k-5k': 0, '5k-20k': 0, '≥ 20k': 0 };
merged.forEach((p) => {
const c = p.RatingsCount || 0;
if (c < 100) reviewCountBuckets['< 100']++;
else if (c < 500) reviewCountBuckets['100-500']++;
else if (c < 1000) reviewCountBuckets['500-1k']++;
else if (c < 5000) reviewCountBuckets['1k-5k']++;
else if (c < 20000) reviewCountBuckets['5k-20k']++;
else reviewCountBuckets['≥ 20k']++;
});
// 销量汇总
const totalMonthlySales = merged.reduce((s, p) => s + (p.ListingSalesVolumeOfMonth || 0), 0);
const totalMonthlyRevenue = merged.reduce((s, p) => s + (p.ListingSalesOfMonth || 0), 0);
// Top 产品(多维度)
const topBySales = merged.slice(0, 10);
const topByRating = merged.filter((p) => p.Ratings >= 4.5 && p.RatingsCount >= 500)
.sort((a, b) => (b.RatingsCount || 0) - (a.RatingsCount || 0)).slice(0, 8);
const topByReviews = merged.slice().sort((a, b) => (b.RatingsCount || 0) - (a.RatingsCount || 0)).slice(0, 8);
// 详情数据聚合(有 ProductRequest 的产品)
const detailedAsins = Object.keys(details);
const detailProducts = detailedAsins.map((asin) => {
const d = details[asin];
const m = merged.find((p) => p.Asin === asin);
return {
asin,
title: d.Title || m?.Title,
brand: d.Brand || m?.Brand,
price: d.SalesPrice || d.Price || m?.SalesPrice || m?.Price || 0,
rating: d.Ratings || m?.Ratings,
ratingsCount: d.RatingsCount || m?.RatingsCount,
bsrRank: d.BsrRank,
bsrCategory: d.BsrCategory?.[0]?.Name || d.Category?.[0]?.Name,
buyboxSeller: d.BuyboxSeller,
onlineDate: d.OnlineDate,
onlineDays: d.OnlineDays,
profit: d.Profit,
profitRate: d.ProfitRate,
photo: d.Photo?.[0] || d.Photo?.[0]?.Url,
monthlySales: d.ListingSalesVolumeOfMonth || m?.ListingSalesVolumeOfMonth,
monthlyRevenue: d.ListingSalesOfMonth || m?.ListingSalesOfMonth,
hasVideo: d.HasVideo,
aplus: d.APlus,
brandStore: d.HasBrandStore,
variantCount: d.VariationASINCount,
bsrTrend: d.BsrRankTrend || [],
};
}).filter((p) => p.asin);
// 评论摘要(仅抓到评论的产品)· Sorftime 返回数字索引对象 {0:{...},1:{...}}
const reviewsSummary = {};
const normalizeReviews = (rv) => {
if (!rv) return [];
if (Array.isArray(rv)) return rv;
if (Array.isArray(rv.Reviews)) return rv.Reviews;
if (Array.isArray(rv.reviews)) return rv.reviews;
// Sorftime 原始:{0:{...},1:{...}} 格式
return Object.values(rv).filter((x) =>
x && typeof x === 'object' && (x.Star !== undefined || x.Rating !== undefined)
);
};
const rating = (r) => parseFloat(r.Star ?? r.Rating ?? r.rating ?? 0);
for (const asin of Object.keys(reviewsByAsin)) {
const reviews = normalizeReviews(reviewsByAsin[asin]);
const product = merged.find((p) => p.Asin === asin);
if (!reviews.length) continue;
const starDist = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
reviews.forEach((r) => {
const s = Math.round(rating(r));
if (s >= 1 && s <= 5) starDist[s]++;
});
const positiveReviews = reviews.filter((r) => rating(r) >= 4);
const negativeReviews = reviews.filter((r) => rating(r) <= 2);
reviewsSummary[asin] = {
asin,
title: product?.Title?.slice(0, 60),
brand: product?.Brand,
total: reviews.length,
starDist,
positiveReviews: positiveReviews.slice(0, 5),
negativeReviews: negativeReviews.slice(0, 5),
averageRating: reviews.reduce((s, r) => s + rating(r), 0) / reviews.length,
};
}
// 上市时间分布(新品 vs 老品)
const onlineAges = detailProducts.map((p) => p.onlineDays).filter((x) => x > 0);
const newProducts = detailProducts.filter((p) => (p.onlineDays || 0) < 180).length;
const matureProducts = detailProducts.filter((p) => (p.onlineDays || 0) >= 365).length;
// ========== 分析维度 1:英文 VOC 主题提取 ==========
const amazonThemes = extractAmazonThemes(reviewsSummary);
// ========== 分析维度 2:Top 5 竞品深度画像 ==========
const competitorDossiers = buildCompetitorDossiers({
topBrands, merged, detailProducts, reviewsSummary, avgRating, priceStats,
});
// ========== 分析维度 3:品类机会评分卡 ==========
const opportunity = buildOpportunityScore({
top5Share, hhi, priceStats, priceBuckets, avgRating, medianRating,
ratingBuckets, reviewCountBuckets, total: merged.length,
newProducts, matureProducts, avgOnlineDays: onlineAges.length ? Math.round(onlineAges.reduce((s, x) => s + x, 0) / onlineAges.length) : 0,
});
// ========== 分析维度 4:核心发现 + 战略建议 ==========
const keyFindings = buildKeyFindings({
top5Share, hhi, priceStats, avgRating, total: merged.length,
newProducts, matureProducts, themes: amazonThemes,
topBrand: topBrands[0],
});
const strategy = buildStrategyRecommendations({
opportunity, themes: amazonThemes, priceStats, top5Share, avgRating,
});
return {
empty: false,
products: merged,
total: merged.length,
keywords: Object.keys(amz.by_keyword || {}),
priceStats, priceBuckets,
topBrands, top5Share, hhi,
avgRating, medianRating, ratingBuckets,
reviewCountBuckets,
totalMonthlySales, totalMonthlyRevenue,
topBySales, topByRating, topByReviews,
detailProducts, reviewsSummary,
newProducts, matureProducts, avgOnlineDays: onlineAges.length ? Math.round(onlineAges.reduce((s, x) => s + x, 0) / onlineAges.length) : 0,
// 分析层(香薰报告式)
amazonThemes,
competitorDossiers,
opportunity,
keyFindings,
strategy,
};
}
// ============================================================
// Amazon 英文 VOC 主题提取(保健食品品类专用)
// ============================================================
function extractAmazonThemes(reviewsSummary) {
// 汇总所有正负评论文本
const allReviews = [];
Object.values(reviewsSummary || {}).forEach((r) => {
(r.positiveReviews || []).forEach((x) => allReviews.push({
text: String(x.Content || x.content || '').toLowerCase(),
rating: parseFloat(x.Star ?? x.Rating ?? 5),
brand: r.brand,
asin: r.asin,
}));
(r.negativeReviews || []).forEach((x) => allReviews.push({
text: String(x.Content || x.content || '').toLowerCase(),
rating: parseFloat(x.Star ?? x.Rating ?? 1),
brand: r.brand,
asin: r.asin,
}));
});
// 英文保健食品痛点词典(每组关键词 → 统一标签)
const painPatterns = [
{ kw: ['side effect', 'stomach', 'nausea', 'headache', 'diarrhea', 'upset stomach', 'cramp'], tag: 'Side Effects · 副作用担忧', icon: '⚠️', emoji: '🩹' },
{ kw: ["doesn't work", 'not work', 'no effect', 'ineffective', 'no difference', 'waste of money'], tag: 'No Results · 效果无感', icon: '❌', emoji: '💸' },
{ kw: ['bad taste', 'tastes bad', 'hard to swallow', 'too big', 'smell bad', 'awful taste', 'chalky', 'bitter'], tag: 'Bad Taste/Form · 口感与剂型差', icon: '😖', emoji: '👅' },
{ kw: ['expensive', 'overpriced', 'not worth', 'too pricey', 'price is high', 'ripoff'], tag: 'Overpriced · 价格感知差', icon: '💰', emoji: '💵' },
{ kw: ['fake', 'counterfeit', 'not genuine', 'scam', 'suspicious', 'looks off'], tag: 'Authenticity Concern · 信任危机', icon: '🚨', emoji: '🔒' },
{ kw: ['arrived damaged', 'broken', 'leaking', 'crushed', 'seal broken', 'bottle damaged'], tag: 'Packaging Issues · 包装问题', icon: '📦', emoji: '📦' },
{ kw: ['small', 'low dose', 'few pills', 'only lasts', 'ran out', 'not enough'], tag: 'Small Quantity · 量少不划算', icon: '📉', emoji: '⏳' },
];
const brightPatterns = [
{ kw: ['works great', 'really works', 'works well', 'effective', 'noticed a difference', 'noticeable', 'feel better'], tag: 'Effective · 效果认可', icon: '✅', emoji: '⭐' },
{ kw: ['easy to swallow', 'easy to take', 'no aftertaste', 'tastes good', 'tastes great', 'smooth'], tag: 'Easy to Take · 易服用', icon: '💊', emoji: '😊' },
{ kw: ['no side effect', 'gentle', 'no upset', 'easy on stomach', 'well tolerated'], tag: 'Gentle · 温和友好', icon: '🌿', emoji: '🫶' },
{ kw: ['good value', 'worth the price', 'great price', 'affordable', 'bang for buck', 'reasonable'], tag: 'Good Value · 性价比好', icon: '💲', emoji: '💰' },
{ kw: ['recommend', 'highly recommend', 'will buy again', 'repurchase', 'loyal customer', 'great product'], tag: 'Recommend/Repurchase · 推荐复购', icon: '❤️', emoji: '🔁' },
{ kw: ['high quality', 'premium', 'clean ingredients', 'natural', 'organic', 'non-gmo'], tag: 'Quality Ingredients · 优质原料', icon: '🏆', emoji: '🌱' },
{ kw: ['fast shipping', 'arrived quickly', 'well packed', 'packaging', 'secure'], tag: 'Good Logistics · 物流包装好', icon: '📦', emoji: '🚚' },
];
const scenarioPatterns = [
{ kw: ['after drinking', 'hangover', 'party', 'alcohol', 'night out', 'social event'], tag: 'After Drinking · 应酬/宿醉' },
{ kw: ['daily routine', 'every morning', 'with breakfast', 'daily', 'supplement routine'], tag: 'Daily Routine · 日常养护' },
{ kw: ['travel', 'trip', 'vacation', 'on the go', 'busy'], tag: 'Travel/On-the-go · 差旅/便携' },
{ kw: ['doctor', 'physician', 'recommended by', 'medical', 'gi', 'ibs', 'gastroenterologist'], tag: 'Doctor-Recommended · 医生建议' },
{ kw: ['gift', 'christmas', 'birthday', 'for my', 'dad', 'mom', 'husband', 'wife'], tag: 'Gifting · 馈赠场景' },
{ kw: ['weight', 'diet', 'exercise', 'fitness', 'workout'], tag: 'Fitness/Diet · 健身饮食' },
{ kw: ['stress', 'anxiety', 'sleep', 'mood', 'energy', 'fatigue', 'tired'], tag: 'Stress/Energy · 情绪精力' },
];
const classify = (patterns, reviews) => {
const tagData = {};
reviews.forEach((rv) => {
for (const p of patterns) {
if (p.kw.some((k) => rv.text.includes(k))) {
if (!tagData[p.tag]) tagData[p.tag] = { tag: p.tag, icon: p.icon, emoji: p.emoji, count: 0, examples: [], brands: new Set() };
tagData[p.tag].count++;
if (tagData[p.tag].examples.length < 3 && rv.text.length < 400) {
tagData[p.tag].examples.push({
text: rv.text.length > 220 ? rv.text.slice(0, 220) + '...' : rv.text,
rating: rv.rating,
brand: rv.brand,
asin: rv.asin,
});
}
if (rv.brand) tagData[p.tag].brands.add(rv.brand);
break;
}
}
});
return Object.values(tagData).map((d) => ({
tag: d.tag, icon: d.icon, emoji: d.emoji, count: d.count,
pct: reviews.length ? (d.count / reviews.length * 100) : 0,
examples: d.examples,
brandCoverage: d.brands.size,
})).sort((a, b) => b.count - a.count);
};
const negReviews = allReviews.filter((r) => r.rating <= 3);
const posReviews = allReviews.filter((r) => r.rating >= 4);
return {
totalReviews: allReviews.length,
negReviews: negReviews.length,
posReviews: posReviews.length,
pain: classify(painPatterns, negReviews).slice(0, 6),
bright: classify(brightPatterns, posReviews).slice(0, 6),
scenarios: classify(scenarioPatterns, allReviews).slice(0, 6),
};
}
// ============================================================
// Top 5 竞品深度画像(含定位判断 / 优劣势分析 / 我们的对策)
// ============================================================
function buildCompetitorDossiers({ topBrands, merged, detailProducts, reviewsSummary, avgRating, priceStats }) {
const top5 = topBrands.slice(0, 5);
return top5.map((b) => {
// 该品牌的所有产品
const products = merged.filter((p) => p.Brand === b.brand);
const brandPrices = products.map((p) => (p.SalesPrice || p.Price || 0) / 100).filter((x) => x > 0);
const brandRatings = products.map((p) => parseFloat(p.Ratings || 0)).filter((x) => x > 0);
const totalReviews = products.reduce((s, p) => s + (p.RatingsCount || 0), 0);
const topProduct = products.slice().sort((a, b) => (b.ListingSalesVolumeOfMonth || 0) - (a.ListingSalesVolumeOfMonth || 0))[0];
const detail = detailProducts.find((d) => d.brand === b.brand);
const avgPrice = brandPrices.length ? brandPrices.reduce((s, x) => s + x, 0) / brandPrices.length : 0;
const avgRt = brandRatings.length ? brandRatings.reduce((s, x) => s + x, 0) / brandRatings.length : 0;
// 定位判断
const priceVsCategory = avgPrice - priceStats.mean;
const ratingVsCategory = avgRt - avgRating;
let positioning, positionColor;
if (b.share >= 25 && avgRt >= avgRating) {
positioning = 'Category Leader · 类目领导者';
positionColor = 'amber';
} else if (priceVsCategory > 5 && avgRt >= 4.3) {
positioning = 'Premium Player · 高端玩家';
positionColor = 'purple';
} else if (priceVsCategory < -3 && b.share >= 5) {
positioning = 'Value Challenger · 性价比挑战者';
positionColor = 'green';
} else if (totalReviews >= 10000 && avgRt >= 4.3) {
positioning = 'Volume Established · 口碑巨鲸';
positionColor = 'blue';
} else if ((detail?.onlineDays || 0) < 365 && avgRt >= 4.3) {
positioning = 'Rising Star · 成长新秀';
positionColor = 'rose';
} else if (avgRt < avgRating - 0.2) {
positioning = 'Weak Link · 口碑软肋';
positionColor = 'rose';
} else {
positioning = 'Niche Specialist · 垂类玩家';
positionColor = 'blue';
}
// 聚合该品牌所有评论(跨 ASIN)的关键词
const brandAsins = products.map((p) => p.Asin);
const brandReviews = [];
brandAsins.forEach((a) => {
const r = reviewsSummary[a];
if (!r) return;
brandReviews.push(...(r.positiveReviews || []).map((x) => ({ text: String(x.Content || x.content || '').toLowerCase(), kind: 'pos' })));
brandReviews.push(...(r.negativeReviews || []).map((x) => ({ text: String(x.Content || x.content || '').toLowerCase(), kind: 'neg' })));
});
// 提炼优势 / 劣势(基于关键词频次)
const scoreKw = (reviews, list) => {
const out = {};
reviews.forEach((rv) => {
list.forEach((item) => {
if (item.kw.some((k) => rv.text.includes(k))) {
out[item.label] = (out[item.label] || 0) + 1;
}
});
});
return Object.entries(out).sort((a, b) => b[1] - a[1]).slice(0, 3).map(([label, n]) => ({ label, n }));
};
const strengths = scoreKw(brandReviews.filter((r) => r.kind === 'pos'), [
{ kw: ['works', 'effective', 'noticed'], label: '效果被认可' },
{ kw: ['easy to swallow', 'easy to take', 'no aftertaste'], label: '服用体验好' },
{ kw: ['value', 'affordable', 'worth'], label: '性价比突出' },
{ kw: ['recommend', 'buy again', 'repurchase'], label: '高复购意愿' },
{ kw: ['quality', 'premium', 'natural', 'organic'], label: '原料/工艺优势' },
{ kw: ['gentle', 'no side'], label: '温和无副作用' },
]);
const weaknesses = scoreKw(brandReviews.filter((r) => r.kind === 'neg'), [
{ kw: ['side effect', 'stomach', 'headache'], label: '副作用投诉' },
{ kw: ["doesn't work", 'no effect', 'waste'], label: '效果受质疑' },
{ kw: ['bad taste', 'smell', 'hard to swallow', 'chalky'], label: '口感/剂型差' },
{ kw: ['expensive', 'overpriced', 'ripoff'], label: '价格争议' },
{ kw: ['fake', 'counterfeit', 'scam'], label: '真伪投诉' },
{ kw: ['damaged', 'leaking', 'broken seal'], label: '物流损坏' },
{ kw: ['small', 'few', 'ran out'], label: '量少争议' },
]);
return {
brand: b.brand,
share: b.share,
sales: b.sales,
skuCount: b.count,
totalReviews,
avgPrice,
avgRating: avgRt,
topProduct: topProduct ? {
asin: topProduct.Asin, title: topProduct.Title,
price: (topProduct.SalesPrice || topProduct.Price || 0) / 100,
rating: parseFloat(topProduct.Ratings || 0),
reviewCount: topProduct.RatingsCount || 0,
monthlySales: topProduct.ListingSalesVolumeOfMonth || 0,
} : null,
detail,
positioning, positionColor,
strengths, weaknesses,
priceVsCategory: +priceVsCategory.toFixed(1),
ratingVsCategory: +ratingVsCategory.toFixed(2),
};
});
}
// ============================================================
// 品类机会评分卡(4 维打分 + 综合等级)
// ============================================================
function buildOpportunityScore({
top5Share, hhi, priceStats, priceBuckets, avgRating, medianRating,
ratingBuckets, reviewCountBuckets, total, newProducts, matureProducts,
}) {
// === 维度 1:市场集中度(越分散越好进入)===
let concLevel, concScore, concNote;
if (top5Share < 35) { concLevel = 'FRAGMENTED'; concScore = 5; concNote = '前5品牌仅占 ' + top5Share.toFixed(1) + '%,市场高度分散,**新玩家进入门槛低**'; }
else if (top5Share < 55) { concLevel = 'MODERATE'; concScore = 3; concNote = '前5品牌占 ' + top5Share.toFixed(1) + '%,**需差异化定位**才能切入'; }
else { concLevel = 'CONCENTRATED'; concScore = 1; concNote = '前5品牌垄断 ' + top5Share.toFixed(1) + '%(HHI=' + Math.round(hhi) + '),**正面硬拼不可取**'; }
// === 维度 2:价格进入难度 ===
const priceSpread = priceStats.max - priceStats.min;
let priceLevel, priceScore, priceNote, sweetSpot;
const sortedBuckets = Object.entries(priceBuckets).sort((a, b) => b[1] - a[1]);
sweetSpot = sortedBuckets[0]?.[0] || '$15-25';
if (priceSpread > 40) { priceLevel = 'WIDE RANGE'; priceScore = 4; priceNote = '价格跨度 $' + priceStats.min.toFixed(0) + '-$' + priceStats.max.toFixed(0) + ',各带均有生存空间。甜点价位 **' + sweetSpot + '** (' + sortedBuckets[0][1] + ' 款)'; }
else if (priceSpread > 20) { priceLevel = 'BALANCED'; priceScore = 3; priceNote = '价格带分布合理,主流集中在 **' + sweetSpot + '** (' + sortedBuckets[0][1] + ' 款,占 ' + ((sortedBuckets[0][1] / total * 100).toFixed(1)) + '%)'; }
else { priceLevel = 'NARROW'; priceScore = 2; priceNote = '价格高度集中在 ' + sweetSpot + ',**错位定价机会少**'; }
// === 维度 3:质量门槛(基于评分分布)===
const highRatingCount = (ratingBuckets['≥ 4.7'] || 0) + (ratingBuckets['4.5-4.7'] || 0);
const highRatingPct = total ? (highRatingCount / total * 100) : 0;
let qualityLevel, qualityScore, qualityNote;
if (avgRating >= 4.5 && highRatingPct >= 60) { qualityLevel = 'HIGH BAR'; qualityScore = 2; qualityNote = '行业均评 ' + avgRating.toFixed(2) + ',' + highRatingPct.toFixed(0) + '% 产品 ≥4.5 分,**质量门槛极高**,需做到 4.6+ 才有竞争力'; }
else if (avgRating >= 4.3) { qualityLevel = 'STANDARD'; qualityScore = 4; qualityNote = '均评 ' + avgRating.toFixed(2) + ',**质量及格线清晰**,做到 4.5+ 可拉开差距'; }
else { qualityLevel = 'LOW BAR'; qualityScore = 5; qualityNote = '均评仅 ' + avgRating.toFixed(2) + ',**质量分化大**,做到 4.5+ 即可脱颖而出'; }
// === 维度 4:评论沉淀壁垒 ===
const highReviewCount = (reviewCountBuckets['≥ 20k'] || 0) + (reviewCountBuckets['5k-20k'] || 0) + (reviewCountBuckets['1k-5k'] || 0);
const lowReviewCount = (reviewCountBuckets['< 100'] || 0) + (reviewCountBuckets['100-500'] || 0);
let reviewLevel, reviewScore, reviewNote;
if (highReviewCount / total > 0.5) { reviewLevel = 'DEEP MOAT'; reviewScore = 2; reviewNote = '过半产品评论 ≥1k,**口碑护城河极深**,新品冷启动周期长'; }
else if (highReviewCount / total > 0.3) { reviewLevel = 'MODERATE MOAT'; reviewScore = 3; reviewNote = highReviewCount + ' 款产品评论 ≥1k(' + ((highReviewCount / total * 100).toFixed(0)) + '%),**老品有口碑壁垒但不算高不可攀**'; }
else { reviewLevel = 'LOW MOAT'; reviewScore = 5; reviewNote = lowReviewCount + ' 款产品评论 <500(' + ((lowReviewCount / total * 100).toFixed(0)) + '%),**评论壁垒低,新品有快速起量空间**'; }
// === 维度 5:增长信号(新品比例)===
const totalDetailed = Math.max(1, newProducts + matureProducts);
const newRatio = (newProducts / totalDetailed) * 100;
let growthLevel, growthScore, growthNote;
if (newRatio >= 40) { growthLevel = 'ACCELERATING'; growthScore = 5; growthNote = '详情样本中新品占 ' + newRatio.toFixed(0) + '%(<180d),**品类在快速扩容**'; }
else if (newRatio >= 20) { growthLevel = 'STEADY'; growthScore = 3; growthNote = '新品占 ' + newRatio.toFixed(0) + '%,**品类稳态**,老品与新品并存'; }
else { growthLevel = 'MATURE'; growthScore = 2; growthNote = '新品仅占 ' + newRatio.toFixed(0) + '%,**品类成熟**,迭代节奏慢'; }
const totalScore = concScore + priceScore + qualityScore + reviewScore + growthScore;
const maxScore = 5 * 5;
const grade = totalScore / maxScore >= 0.75 ? 'A · 强烈推荐'
: totalScore / maxScore >= 0.6 ? 'B · 推荐'
: totalScore / maxScore >= 0.45 ? 'C · 可选'
: 'D · 需谨慎';
const gradeColor = totalScore / maxScore >= 0.75 ? 'green'
: totalScore / maxScore >= 0.6 ? 'amber'
: totalScore / maxScore >= 0.45 ? 'blue'
: 'rose';
return {
totalScore, maxScore, grade, gradeColor,
sweetSpot,
dimensions: [
{ name: '市场集中度', level: concLevel, score: concScore, note: concNote, icon: '🎯' },
{ name: '价格切入', level: priceLevel, score: priceScore, note: priceNote, icon: '💲' },
{ name: '质量门槛', level: qualityLevel, score: qualityScore, note: qualityNote, icon: '⭐' },
{ name: '评论壁垒', level: reviewLevel, score: reviewScore, note: reviewNote, icon: '💬' },
{ name: '增长信号', level: growthLevel, score: growthScore, note: growthNote, icon: '📈' },
],
};
}
// ============================================================
// 核心发现(3-4 条可直接用于 Executive Summary 的判断)
// ============================================================
function buildKeyFindings({ top5Share, hhi, priceStats, avgRating, total, newProducts, matureProducts, themes, topBrand }) {
const findings = [];
// Finding 1:结构
findings.push({
icon: top5Share < 40 ? '✅' : top5Share < 60 ? '⚠️' : '❌',
title: top5Share < 40 ? '市场分散可进入' : top5Share < 60 ? '中度集中需差异化' : '寡头垄断需谨慎',
body: '前5品牌占 ' + top5Share.toFixed(1) + '%(HHI=' + Math.round(hhi) + ')'
+ (topBrand ? ',龙头 ' + topBrand.brand + ' 独占 ' + topBrand.share.toFixed(1) + '%' : '')
+ '。' + (top5Share < 40 ? '新玩家有窗口,关键是找差异化钩子。' : top5Share < 60 ? '硬拼难赢,建议从头部未覆盖的痛点切入。' : '建议做补位者而非挑战者。'),
tone: top5Share < 40 ? 'green' : top5Share < 60 ? 'amber' : 'rose',
});
// Finding 2:价格
findings.push({
icon: '💲',
title: '定价锚点 $' + priceStats.median.toFixed(1) + ',建议价位 $' + (Math.round(priceStats.median * 0.9) + '-' + Math.round(priceStats.median * 1.2)),
body: '行业均价 $' + priceStats.mean.toFixed(1) + ',中位数 $' + priceStats.median.toFixed(1) + '($' + priceStats.min.toFixed(0) + '-$' + priceStats.max.toFixed(0) + ')。'
+ '消费者对此品类的价格期望已形成锚定,新品建议贴近中位数 ±15%,过高需强差异化支撑,过低会被误判为低质。',
tone: 'blue',
});
// Finding 3:质量门槛
findings.push({
icon: avgRating >= 4.5 ? '🏆' : avgRating >= 4.3 ? '⭐' : '⚠️',
title: '质量基线 ' + avgRating.toFixed(2) + ' 星 · ' + (avgRating >= 4.5 ? '高' : avgRating >= 4.3 ? '标准' : '偏低'),
body: '行业均评 ' + avgRating.toFixed(2) + '。'
+ (avgRating >= 4.5 ? '这是一个质量门槛极高的品类,新品不做到 4.6+ 很难撑起单品。' : avgRating >= 4.3 ? '做到 4.5+ 即可拉开差距。' : '口碑分化明显,有质量红利可吃。')
+ (themes.pain[0] ? '最高频痛点是 ' + themes.pain[0].tag + '(' + themes.pain[0].count + ' 次提及),这是第一优化方向。' : ''),
tone: avgRating >= 4.5 ? 'rose' : avgRating >= 4.3 ? 'amber' : 'green',
});
// Finding 4:用户原声主线
if (themes.bright[0] && themes.pain[0]) {
findings.push({
icon: '🗣️',
title: '用户原声:「' + (themes.bright[0].tag.split(' · ')[1] || themes.bright[0].tag) + '」是第一加分项',
body: '好评 Top 1:' + themes.bright[0].tag + '(' + themes.bright[0].count + ' 次,覆盖 ' + themes.bright[0].brandCoverage + ' 家品牌)'
+ ';差评 Top 1:' + themes.pain[0].tag + '(' + themes.pain[0].count + ' 次)。'
+ '营销应将「' + (themes.bright[0].tag.split(' · ')[1] || themes.bright[0].tag) + '」作为 hero claim,产品端重点规避「' + (themes.pain[0].tag.split(' · ')[1] || themes.pain[0].tag) + '」。',
tone: 'purple',
});
}
return findings;
}
// ============================================================
// 战略建议(短/中/长期 3 轨)
// ============================================================
function buildStrategyRecommendations({ opportunity, themes, priceStats, top5Share, avgRating }) {
const sweetPrice = '$' + Math.round(priceStats.median * 0.9) + '-$' + Math.round(priceStats.median * 1.2);
const topPain = themes.pain[0];
const topBright = themes.bright[0];
const topScene = themes.scenarios[0];
return {
short: {
title: '0-30 天 · 选品与立项',
badge: 'QUICK WIN',
items: [
'定价锚点 ' + sweetPrice + '(贴近行业中位数 ±15%),避免"高不成低不就"',
topPain ? '产品 PRD 首要规避 ' + (topPain.tag.split(' · ')[1] || topPain.tag) + '(差评 Top1, ' + topPain.count + ' 次提及)' : '基于品类痛点做产品 PRD 差异化',
'参考 ' + opportunity.sweetSpot + ' 价位 Top 3 款做 listing 结构逆向拆解(标题/图/A+内容)',
],
},
mid: {
title: '30-90 天 · 冷启动与放量',
badge: 'SCALE',
items: [
topBright ? '营销 hero claim 直接挪用高频好评词 「' + (topBright.tag.split(' · ')[1] || topBright.tag) + '」(' + topBright.count + ' 次提及)' : '营销 hero claim 基于品类好评共识',
topScene ? '种草场景聚焦 ' + (topScene.tag.split(' · ')[1] || topScene.tag) + '(' + topScene.count + ' 次用户自发提及)' : '种草场景聚焦用户自述的高频使用时刻',
avgRating >= 4.5 ? '保持 ≥4.6 评分是生死线,Review seeding + Vine 必须做满 50 条' : '保持 ≥4.5 即可拉开差距,Review seeding 20-30 条足够起量',
top5Share >= 55 ? '不正面硬拼头部品牌,用长尾关键词 ASIN Targeting 抢流量' : '可直接 Brand Defense + Category ASIN Targeting 双线抢位',
],
},
long: {
title: '90 天+ · 沉淀与破圈',
badge: 'MOAT',
items: [
'Review 护城河:6 个月内积累 ≥1000 条真评,进入评论壁垒俱乐部',
'Bundle 策略:基于用户多场景(' + themes.scenarios.slice(0, 2).map((s) => s.tag.split(' · ')[1] || s.tag).join(' / ') + ')做跨场景组合装',
'站外破圈:把好评词作为 TikTok / Reddit UGC 素材源,低成本撬动信任',
opportunity.grade.startsWith('A') ? '机会等级 A:建议 6 个月内 ≥3 SKU 系列化铺开' : '机会等级 ' + opportunity.grade.split(' ')[0] + ':先打磨单 SKU 做深,12 个月后再考虑扩线',
],
},
};
}
// ============================================================
// 抖音分析
// ============================================================
function analyzeDouyin(voc) {
const dy = voc.douyin;
if (!dy || dy._pending_cookie) {
return {
empty: true,
pending_cookie: dy?._pending_cookie || false,
share_url: dy?.share_url,
error: dy?._error || dy?._resolve_error,
};
}
const video = dy.video;
const comments = dy.comments || [];
const userPosts = dy.user_posts || [];
const userProfile = dy.user_profile;
const searches = dy.searches || {};
// 评论按点赞排序
const topComments = comments.slice()
.filter((c) => c.text && c.text.length >= 5 && c.text.length <= 200)
.sort((a, b) => (b.digg_count || 0) - (a.digg_count || 0))
.slice(0, 20);
// 评论 IP 分布
const ipDist = {};
comments.forEach((c) => { if (c.ip_label) ipDist[c.ip_label] = (ipDist[c.ip_label] || 0) + 1; });
const topIPs = Object.entries(ipDist).sort((a, b) => b[1] - a[1]).slice(0, 8);
// 作者作品互动均值
const authorStats = userPosts.length ? {
count: userPosts.length,
avgDigg: userPosts.reduce((s, v) => s + (v.statistics?.digg_count || 0), 0) / userPosts.length,
avgComment: userPosts.reduce((s, v) => s + (v.statistics?.comment_count || 0), 0) / userPosts.length,
avgPlay: userPosts.reduce((s, v) => s + (v.statistics?.play_count || 0), 0) / userPosts.length,
} : null;
// 品类搜索覆盖
const searchSummary = {};
for (const kw of Object.keys(searches)) {
const videos = Array.isArray(searches[kw]) ? searches[kw] : [];
searchSummary[kw] = {
count: videos.length,
totalDigg: videos.reduce((s, v) => s + (v.statistics?.digg_count || 0), 0),
totalComment: videos.reduce((s, v) => s + (v.statistics?.comment_count || 0), 0),
top: videos.slice(0, 5).map((v) => ({
aweme_id: v.aweme_id,
desc: v.desc,
digg: v.statistics?.digg_count,
comment: v.statistics?.comment_count,
author: v.author?.nickname,
authorFans: v.author?.follower_count,
cover: v.cover,
})),
};
}
// 作品 Top 8(按点赞)
const topPosts = userPosts.slice()
.sort((a, b) => (b.statistics?.digg_count || 0) - (a.statistics?.digg_count || 0))
.slice(0, 8)
.map((v) => ({
aweme_id: v.aweme_id,
desc: v.desc,
cover: v.cover,
create_time: v.create_time,
digg: v.statistics?.digg_count || 0,
comment: v.statistics?.comment_count || 0,
share: v.statistics?.share_count || 0,
play: v.statistics?.play_count || 0,
collect: v.statistics?.collect_count || 0,
}));
// 评论按 aweme_id 分组
const commentsByPost = {};
comments.forEach((c) => {
const pid = c.aweme_id || (video?.aweme_id);
if (!pid) return;
if (!commentsByPost[pid]) commentsByPost[pid] = [];
commentsByPost[pid].push(c);
});
// === 多账号聚合(如果 dy.accounts 存在)===
const accounts = Array.isArray(dy.accounts) ? dy.accounts : [];
const accountCards = accounts.map((acc) => {
const ap = acc.user_posts || [];
const profile = acc.user_profile || {};
const topDigg = ap.reduce((s, v) => s + (v.statistics?.digg_count || 0), 0);
const topComment = ap.reduce((s, v) => s + (v.statistics?.comment_count || 0), 0);
const topPlay = ap.reduce((s, v) => s + (v.statistics?.play_count || 0), 0);
const best = [...ap].sort((a, b) => (b.statistics?.digg_count || 0) - (a.statistics?.digg_count || 0))[0];
return {
role: acc.role || '官方号',
label: acc.label || acc.role || '账号',
share_url: acc.share_url,
share_type: acc.share_type,
shareType: acc.share_type,
nickname: profile.nickname || '—',
signature: (profile.signature || '').replace(/\n/g, ' '),
avatar: profile.avatar,
custom_verify: profile.custom_verify,
follower_count: profile.follower_count || 0,
following_count: profile.following_count || 0,
aweme_count: profile.aweme_count || 0,
total_favorited: profile.total_favorited || 0,
posts_sampled: ap.length,
sum_digg: topDigg,
sum_comment: topComment,
sum_play: topPlay,
top_post: best ? {
aweme_id: best.aweme_id,
desc: (best.desc || '').replace(/\n/g, ' '),
digg: best.statistics?.digg_count || 0,
comment: best.statistics?.comment_count || 0,
play: best.statistics?.play_count || 0,
cover: best.cover,
} : null,
top_posts: [...ap]
.sort((a, b) => (b.statistics?.digg_count || 0) - (a.statistics?.digg_count || 0))
.slice(0, 4)
.map((v) => ({
aweme_id: v.aweme_id,
desc: (v.desc || '').replace(/\n/g, ' ').slice(0, 80),
digg: v.statistics?.digg_count || 0,
comment: v.statistics?.comment_count || 0,
play: v.statistics?.play_count || 0,
cover: v.cover,
})),
comments_count: (acc.comments || []).length,
error: acc._resolve_error || acc._error,
};
});
// 跨账号汇总 metrics
const aggMetrics = accountCards.length ? {
total_accounts: accountCards.length,
total_followers: accountCards.reduce((s, c) => s + (c.follower_count || 0), 0),
total_posts_sampled: accountCards.reduce((s, c) => s + (c.posts_sampled || 0), 0),
total_digg: accountCards.reduce((s, c) => s + (c.sum_digg || 0), 0),
total_comments: accountCards.reduce((s, c) => s + (c.comments_count || 0), 0),
verified_count: accountCards.filter((c) => c.custom_verify && c.custom_verify.length).length,
} : null;
return {
empty: !video && !userProfile && userPosts.length === 0 && comments.length === 0,
shareType: dy.share_type || (video ? 'video' : 'user'),
video, comments, userPosts, userProfile, searches: searchSummary,
topComments, topIPs, authorStats, topPosts, commentsByPost,
commentsCount: comments.length,
// 新增:多账号聚合(向下兼容,老渲染器忽略即可)
accountCards,
aggMetrics,
};
}
// ============================================================
// TikTok 分析(Amazon 的内容端对标)
// ============================================================
function analyzeTikTok(voc) {
const tt = voc.tiktok;
if (!tt || tt._error) {
return { empty: true, error: tt?._error };
}
const videos = tt.merged_videos || [];
if (!videos.length) {
return { empty: true, keywords_used: tt.keywords_used || [] };
}
// Hero metrics
const totalVideos = videos.length;
const totalDigg = videos.reduce((s, v) => s + (v.statistics?.digg_count || 0), 0);
const totalPlay = videos.reduce((s, v) => s + (v.statistics?.play_count || 0), 0);
const totalComment = videos.reduce((s, v) => s + (v.statistics?.comment_count || 0), 0);
const totalShare = videos.reduce((s, v) => s + (v.statistics?.share_count || 0), 0);
const totalCollect = videos.reduce((s, v) => s + (v.statistics?.collect_count || 0), 0);
const authorSet = new Set(videos.map((v) => v.author?.sec_uid).filter(Boolean));
const verifiedAuthors = videos.filter((v) => v.author?.custom_verify).length;
const verifiedRatio = totalVideos ? verifiedAuthors / totalVideos : 0;
const totalFollowersReached = [...authorSet].reduce((s, uid) => {
const v = videos.find((x) => x.author?.sec_uid === uid);
return s + (v?.author?.follower_count || 0);
}, 0);
// 每关键词统计
const byKeywordStats = {};
for (const [kw, bkt] of Object.entries(tt.by_keyword || {})) {
const vs = bkt?.videos || [];
byKeywordStats[kw] = {
videos: vs.length,
totalDigg: vs.reduce((s, v) => s + (v.statistics?.digg_count || 0), 0),
totalComment: vs.reduce((s, v) => s + (v.statistics?.comment_count || 0), 0),
totalPlay: vs.reduce((s, v) => s + (v.statistics?.play_count || 0), 0),
avgDigg: vs.length ? Math.round(vs.reduce((s, v) => s + (v.statistics?.digg_count || 0), 0) / vs.length) : 0,
};
}
// Top 视频(按赞)
const topVideos = [...videos]
.sort((a, b) => (b.statistics?.digg_count || 0) - (a.statistics?.digg_count || 0))
.slice(0, 12)
.map((v) => ({
aweme_id: v.aweme_id,
desc: (v.desc || '').replace(/\n/g, ' ').slice(0, 180),
cover: v.cover,
digg: v.statistics?.digg_count || 0,
comment: v.statistics?.comment_count || 0,
play: v.statistics?.play_count || 0,
share: v.statistics?.share_count || 0,
collect: v.statistics?.collect_count || 0,
author: v.author ? {
nickname: v.author.nickname,
unique_id: v.author.unique_id,
follower_count: v.author.follower_count,
custom_verify: v.author.custom_verify,
avatar: v.author.avatar,
} : null,
hashtags: v.hashtags || [],
create_time: v.create_time,
region: v.region,
_from_keyword: v._from_keyword,
}));
// 高赞播放比(engagement rate)
const videosWithRate = videos.map((v) => ({
...v,
_er: v.statistics?.play_count > 0
? ((v.statistics.digg_count || 0) / v.statistics.play_count)
: 0,
}));
const avgEngagementRate = videosWithRate.length
? (videosWithRate.reduce((s, v) => s + v._er, 0) / videosWithRate.length)
: 0;
// Hashtag 频率
const hashtagCount = {};
videos.forEach((v) => {
(v.hashtags || []).forEach((h) => {
const tag = h.toLowerCase().trim();
if (tag) hashtagCount[tag] = (hashtagCount[tag] || 0) + 1;
});
});
const topHashtags = Object.entries(hashtagCount)
.sort((a, b) => b[1] - a[1])
.slice(0, 20)
.map(([tag, count]) => ({ tag, count }));
// 地区分布
const regionCount = {};
videos.forEach((v) => {
const r = v.region || 'Unknown';
regionCount[r] = (regionCount[r] || 0) + 1;
});
const topRegions = Object.entries(regionCount).sort((a, b) => b[1] - a[1]).slice(0, 8);
// Top 作者卡(结合 author_profiles)
const topAuthors = (tt.top_authors || []).slice(0, 5).map((a) => {
const profile = tt.author_profiles?.[a.sec_uid] || a;
const posts = tt.author_posts?.[a.sec_uid] || [];
const bestPost = [...posts].sort((x, y) => (y.statistics?.digg_count || 0) - (x.statistics?.digg_count || 0))[0];
return {
sec_uid: a.sec_uid,
unique_id: profile.unique_id || a.unique_id,
nickname: profile.nickname || a.nickname,
follower_count: profile.follower_count || a.follower_count || 0,
total_favorited: profile.total_favorited || a.total_favorited || 0,
aweme_count: profile.aweme_count || a.aweme_count || 0,
custom_verify: profile.custom_verify || a.custom_verify,
signature: (profile.signature || '').replace(/\n/g, ' ').slice(0, 120),
region: profile.region,
avatar: profile.avatar || a.avatar,
sampled_posts: posts.length,
best_post: bestPost ? {
aweme_id: bestPost.aweme_id,
desc: (bestPost.desc || '').replace(/\n/g, ' ').slice(0, 100),
digg: bestPost.statistics?.digg_count || 0,
play: bestPost.statistics?.play_count || 0,
cover: bestPost.cover,
} : null,
};
});
// Top 评论
const allComments = [];
for (const [aid, cms] of Object.entries(tt.top_comments_by_video || {})) {
(cms || []).forEach((c) => allComments.push({ ...c, aweme_id: aid }));
}
const topComments = allComments
.filter((c) => c.text && c.text.length >= 5 && c.text.length <= 280)
.sort((a, b) => (b.digg_count || 0) - (a.digg_count || 0))
.slice(0, 15);
// 品类官方/头部账号(user search 的结果)
const catUsers = [];
for (const users of Object.values(tt.category_users || {})) {
(users || []).forEach((u) => {
if (!catUsers.find((x) => x.sec_uid === u.sec_uid)) catUsers.push(u);
});
}
const topCatUsers = catUsers
.sort((a, b) => (b.follower_count || 0) - (a.follower_count || 0))
.slice(0, 6);
return {
empty: false,
keywords_used: tt.keywords_used || [],
metrics: {
totalVideos,
totalDigg,
totalPlay,
totalComment,
totalShare,
totalCollect,
totalAuthors: authorSet.size,
verifiedAuthors,
verifiedRatio,
totalFollowersReached,
avgEngagementRate,
},
byKeywordStats,
topVideos,
topHashtags,
topRegions,
topAuthors,
topComments,
topCategoryUsers: topCatUsers,
};
}
// ============================================================
// 主入口:聚合所有分析
// ============================================================
function analyze(voc) {
const xhs = analyzeXhs(voc);
const amazon = analyzeAmazon(voc);
const douyin = analyzeDouyin(voc);
const tiktok = analyzeTikTok(voc);
// 兼容旧字段(让旧渲染器不挂)
return {
meta: voc,
metrics: xhs.metrics,
topNotes: xhs.topNotes,
topComments: xhs.topComments,
kols: xhs.kols,
kolTier: xhs.kolTier,
topIPs: xhs.topIPs,
// Amazon 新接口(丰富维度)
amazon,
// 兼容旧字段
amazonProducts: amazon.products || [],
topBrands: (amazon.topBrands || []).map((b) => [b.brand, b.sales]),
priceBuckets: amazon.priceBuckets || {},
// 抖音
douyin,
// TikTok(Amazon 的内容端对标)
tiktok,
// 市场情报(Douyin 站级热榜 + TikTok Ads 官方数据)
marketIntel: analyzeMarketIntel(voc.market_intel),
};
}
// ============================================================
// Market Intel 分析:TikTok Ads 情报 + Douyin 全站热榜摘要
// ============================================================
function analyzeMarketIntel(mi) {
if (!mi) return { empty: true };
// TT Ads:每产品提取 best-performing keyword(以 impression 为序)
const adsEntries = Object.entries(mi.tt_ads_by_keyword || {});
const insightsList = adsEntries
.map(([kw, d]) => d.insights ? { keyword: kw, ...d.insights } : null)
.filter(Boolean)
.sort((a, b) => (b.impression || 0) - (a.impression || 0));
// Related keywords:按 score 排序的 Top 30(来自有数据的第一个关键词)
let relatedKeywords = [];
let relatedFromKw = null;
for (const [kw, d] of adsEntries) {
if (Array.isArray(d.related_keywords) && d.related_keywords.length) {
relatedKeywords = d.related_keywords.slice(0, 30);
relatedFromKw = kw;
break;
}
}
// Douyin 全站热榜采样(与产品无关的"内容生态基线")
const global = mi.trends_global_sample || {};
const hotWordTop = (global.hot_word_top5 || []).slice(0, 6);
const webHotTop = (global.web_hot_top5 || []).slice(0, 6);
const hotTopicTop = (global.hot_topic_top5 || []).slice(0, 5);
// Douyin 品类过滤命中(若有)
const filtered = mi.douyin_filtered || {};
const categoryHitTotal = Object.values(filtered).reduce((s, a) => s + (a?.length || 0), 0);
return {
collected_at: mi.collected_at,
ttAds: {
insights: insightsList, // [{keyword, impression, ctr, cvr, post, post_change, cost, like, comment, share, video_list}]
relatedKeywords, // [{name, score}] Top 30
relatedFromKeyword: relatedFromKw,
hasData: insightsList.length > 0 || relatedKeywords.length > 0,
},
douyinGlobal: {
hotWords: hotWordTop, // [{title, score}]
webHotSearch: webHotTop, // [{word, view_count}]
hotTopics: hotTopicTop, // [{challenge_name, play_cnt, publish_cnt}]
filterKeywords: mi.douyin_filter_keywords || [],
categoryFiltered: filtered, // 本地过滤命中(可能全为 0)
categoryHitTotal,
hasGlobal: hotWordTop.length > 0 || webHotTop.length > 0,
},
};
}
function extractThemes(texts) {
const negKeywords = [
{ kw: ['难吃', '太苦', '味道', '歹毒', '呕', '吐'], tag: '口感差' },
{ kw: ['副作用', '伤', '不适', '难受', '拉肚'], tag: '副作用担忧' },
{ kw: ['假货', '骗', '智商税', '坑', '踩雷'], tag: '信任危机' },
{ kw: ['贵', '太贵', '不划算', '太坑'], tag: '价格敏感' },
{ kw: ['没用', '没效果', '无效', '白吃'], tag: '效果不显' },
];
const posKeywords = [
{ kw: ['好用', '有效', '管用', '效果不错', '真的好'], tag: '效果认可' },
{ kw: ['好吃', '味道不错', '好喝', '喜欢', '期待'], tag: '口感好' },
{ kw: ['回购', '长期', '一直吃', '推荐', '必备'], tag: '高复购意向' },
{ kw: ['舒服', '放心', '安心', '温和'], tag: '体验温和' },
{ kw: ['方便', '便携', '携带'], tag: '便利场景' },
];
const scenarioKeywords = [
{ kw: ['熬夜', '应酬', '喝酒', '酒局', '宿醉'], tag: '应酬场景' },
{ kw: ['上班', '职场', '通勤', '办公室', '加班'], tag: '职场场景' },
{ kw: ['宝宝', '孩子', '儿童', '小朋友', '宝妈'], tag: '育儿场景' },
{ kw: ['减肥', '减脂', '控制', '轻断食'], tag: '减肥场景' },
{ kw: ['养胃', '胃不好', '胃痛', '消化', '肠胃'], tag: '胃肠养护' },
];
const count = (patterns, texts) => {
const tagCount = {}, tagExamples = {};
for (const text of texts) {
for (const { kw, tag } of patterns) {
if (kw.some((k) => text.includes(k))) {
tagCount[tag] = (tagCount[tag] || 0) + 1;
if (!tagExamples[tag]) tagExamples[tag] = [];
if (tagExamples[tag].length < 3) tagExamples[tag].push(text);
}
}
}
return Object.entries(tagCount).map(([tag, count]) => ({ tag, count, examples: tagExamples[tag] || [] }))
.sort((a, b) => b.count - a.count);
};
return {
pain: count(negKeywords, texts),
bright: count(posKeywords, texts),
scenarios: count(scenarioKeywords, texts),
};
}
module.exports = { analyze, extractThemes, esc, fmtNum, fmtMoney, cleanText };