// 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 };