| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151 |
- // VOC 数据分析模块 · v2(增强版)
- const esc = (s) => String(s == null ? '' : s)
- .replace(/&/g, '&').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品牌占 <strong>' + top5Share.toFixed(1) + '%</strong>(HHI=' + Math.round(hhi) + ')'
- + (topBrand ? ',龙头 <strong>' + topBrand.brand + '</strong> 独占 ' + 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) + ')。'
- + '消费者对此品类的价格期望已形成锚定,<strong>新品建议贴近中位数 ±15%</strong>,过高需强差异化支撑,过低会被误判为低质。',
- tone: 'blue',
- });
- // Finding 3:质量门槛
- findings.push({
- icon: avgRating >= 4.5 ? '🏆' : avgRating >= 4.3 ? '⭐' : '⚠️',
- title: '质量基线 ' + avgRating.toFixed(2) + ' 星 · ' + (avgRating >= 4.5 ? '高' : avgRating >= 4.3 ? '标准' : '偏低'),
- body: '行业均评 <strong>' + avgRating.toFixed(2) + '</strong>。'
- + (avgRating >= 4.5 ? '这是一个质量门槛极高的品类,新品不做到 4.6+ 很难撑起单品。' : avgRating >= 4.3 ? '做到 4.5+ 即可拉开差距。' : '口碑分化明显,有质量红利可吃。')
- + (themes.pain[0] ? '最高频痛点是 <strong>' + themes.pain[0].tag + '</strong>(' + 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:<strong>' + themes.bright[0].tag + '</strong>(' + themes.bright[0].count + ' 次,覆盖 ' + themes.bright[0].brandCoverage + ' 家品牌)'
- + ';差评 Top 1:<strong>' + themes.pain[0].tag + '</strong>(' + 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: [
- '定价锚点 <strong>' + sweetPrice + '</strong>(贴近行业中位数 ±15%),避免"高不成低不就"',
- topPain ? '产品 PRD 首要规避 <strong>' + (topPain.tag.split(' · ')[1] || topPain.tag) + '</strong>(差评 Top1, ' + topPain.count + ' 次提及)' : '基于品类痛点做产品 PRD 差异化',
- '参考 ' + opportunity.sweetSpot + ' 价位 Top 3 款做 listing 结构逆向拆解(标题/图/A+内容)',
- ],
- },
- mid: {
- title: '30-90 天 · 冷启动与放量',
- badge: 'SCALE',
- items: [
- topBright ? '营销 hero claim 直接挪用高频好评词 <strong>「' + (topBright.tag.split(' · ')[1] || topBright.tag) + '」</strong>(' + topBright.count + ' 次提及)' : '营销 hero claim 基于品类好评共识',
- topScene ? '种草场景聚焦 <strong>' + (topScene.tag.split(' · ')[1] || topScene.tag) + '</strong>(' + 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: [
- '<strong>Review 护城河</strong>:6 个月内积累 ≥1000 条真评,进入评论壁垒俱乐部',
- '<strong>Bundle 策略</strong>:基于用户多场景(' + themes.scenarios.slice(0, 2).map((s) => s.tag.split(' · ')[1] || s.tag).join(' / ') + ')做跨场景组合装',
- '<strong>站外破圈</strong>:把好评词作为 TikTok / Reddit UGC 素材源,低成本撬动信任',
- opportunity.grade.startsWith('A') ? '<strong>机会等级 A</strong>:建议 6 个月内 ≥3 SKU 系列化铺开' : '<strong>机会等级 ' + opportunity.grade.split(' ')[0] + '</strong>:先打磨单 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 };
|