voc-analyze.js 57 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151
  1. // VOC 数据分析模块 · v2(增强版)
  2. const esc = (s) => String(s == null ? '' : s)
  3. .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
  4. .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
  5. const fmtNum = (n) => {
  6. if (n == null || isNaN(n)) return '—';
  7. if (n >= 1e8) return (n / 1e8).toFixed(1) + '亿';
  8. if (n >= 1e4) return (n / 1e4).toFixed(1) + 'w';
  9. if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
  10. return String(n);
  11. };
  12. const fmtMoney = (cents) => {
  13. const dollars = (cents || 0) / 100;
  14. if (dollars >= 1e6) return '$' + (dollars / 1e6).toFixed(1) + 'M';
  15. if (dollars >= 1e3) return '$' + (dollars / 1e3).toFixed(1) + 'k';
  16. return '$' + dollars.toFixed(2);
  17. };
  18. const cleanText = (s) => String(s || '')
  19. .replace(/\[[^\]]{1,6}R\]/g, '').replace(/\s+/g, ' ').trim();
  20. const median = (arr) => {
  21. if (!arr.length) return 0;
  22. const sorted = arr.slice().sort((a, b) => a - b);
  23. const mid = Math.floor(sorted.length / 2);
  24. return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
  25. };
  26. // ============================================================
  27. // XHS 分析
  28. // ============================================================
  29. function analyzeXhs(voc) {
  30. const allNotes = [], allComments = [], allUsers = {};
  31. for (const kw of Object.keys(voc.xiaohongshu?.by_keyword || {})) {
  32. const d = voc.xiaohongshu.by_keyword[kw];
  33. (d.notes || []).forEach((n) => allNotes.push({ ...n, _keyword: kw }));
  34. for (const nid of Object.keys(d.comments || {})) {
  35. (d.comments[nid] || []).forEach((c) => allComments.push({ ...c, _note_id: nid, _keyword: kw }));
  36. }
  37. for (const uid of Object.keys(d.users || {})) {
  38. if (!allUsers[uid]) allUsers[uid] = d.users[uid];
  39. }
  40. }
  41. const noteMap = new Map();
  42. allNotes.forEach((n) => { if (n.id && !noteMap.has(n.id)) noteMap.set(n.id, n); });
  43. const notes = Array.from(noteMap.values());
  44. const commentMap = new Map();
  45. allComments.forEach((c) => { if (c.id && !commentMap.has(c.id)) commentMap.set(c.id, c); });
  46. const comments = Array.from(commentMap.values());
  47. const metrics = {
  48. notes: notes.length,
  49. totalLikes: notes.reduce((s, n) => s + (n.liked_count || 0), 0),
  50. totalComments: notes.reduce((s, n) => s + (n.comments_count || 0), 0),
  51. totalCollected: notes.reduce((s, n) => s + (n.collected_count || 0), 0),
  52. totalShared: notes.reduce((s, n) => s + (n.shared_count || 0), 0),
  53. sampleComments: comments.length,
  54. };
  55. const topNotes = notes.slice().sort((a, b) => (b.liked_count || 0) - (a.liked_count || 0)).slice(0, 12);
  56. const topComments = comments.filter((c) => c.content && c.content.length >= 8 && c.content.length <= 300)
  57. .slice().sort((a, b) => (b.like_count || 0) - (a.like_count || 0)).slice(0, 20);
  58. const allKols = Object.values(allUsers).filter((u) => u && u.nickname).map((u) => {
  59. const fans = u.fans || (u.interactions || []).find((x) => x.type === 'fans')?.count || 0;
  60. const interaction = (u.interactions || []).find((x) => x.type === 'interaction')?.count || 0;
  61. const likes = (u.interactions || []).find((x) => x.type === 'likes')?.count || 0;
  62. return { ...u, fans, interaction, likes };
  63. }).sort((a, b) => b.fans - a.fans);
  64. // KOL 过滤:≥1000 粉丝才算真实作者
  65. const MIN_FANS = 1000;
  66. const kols = allKols.filter((k) => k.fans >= MIN_FANS);
  67. const kolTier = {
  68. head: kols.filter((k) => k.fans >= 500000), // 头部 50w+
  69. mid: kols.filter((k) => k.fans >= 50000 && k.fans < 500000), // 腰部 5w-50w
  70. tail: kols.filter((k) => k.fans >= 5000 && k.fans < 50000), // 尾部 5k-5w
  71. koc: kols.filter((k) => k.fans >= MIN_FANS && k.fans < 5000), // KOC 1k-5k
  72. };
  73. metrics.kols = kols.length;
  74. metrics.kolsTotal = allKols.length;
  75. metrics.kolsFiltered = allKols.length - kols.length;
  76. const ipDist = {};
  77. comments.forEach((c) => { const ip = c.ip_location; if (ip) ipDist[ip] = (ipDist[ip] || 0) + 1; });
  78. const topIPs = Object.entries(ipDist).sort((a, b) => b[1] - a[1]).slice(0, 10);
  79. return { notes, comments, metrics, topNotes, topComments, kols, allKols, kolTier, topIPs };
  80. }
  81. // ============================================================
  82. // Amazon 分析(丰富维度)
  83. // ============================================================
  84. function analyzeAmazon(voc) {
  85. const amz = voc.amazon || voc.sorftime; // 兼容旧格式
  86. if (!amz || !amz.by_keyword) return { products: [], empty: true };
  87. // 合并多关键词的产品池(去重 by ASIN)
  88. let merged = amz.merged_products || [];
  89. if (!merged.length) {
  90. // 从 by_keyword 合并
  91. const asinMap = new Map();
  92. for (const kw of Object.keys(amz.by_keyword)) {
  93. const ps = amz.by_keyword[kw]?.top_products?.Products
  94. || amz.by_keyword[kw]?.top_products?.Data?.Products
  95. || [];
  96. for (const p of ps) {
  97. if (!p.Asin) continue;
  98. const cur = asinMap.get(p.Asin);
  99. if (!cur || (p.ListingSalesVolumeOfMonth || 0) > (cur.ListingSalesVolumeOfMonth || 0)) {
  100. asinMap.set(p.Asin, { ...p, _from_keyword: kw });
  101. }
  102. }
  103. }
  104. merged = Array.from(asinMap.values())
  105. .sort((a, b) => (b.ListingSalesVolumeOfMonth || 0) - (a.ListingSalesVolumeOfMonth || 0));
  106. }
  107. if (!merged.length) return { products: [], empty: true };
  108. const details = amz.product_details || {};
  109. const reviewsByAsin = amz.reviews_by_asin || {};
  110. // 价格统计 (分 → 美元)
  111. const prices = merged.map((p) => (p.SalesPrice || p.Price || 0) / 100).filter((x) => x > 0);
  112. const priceStats = {
  113. count: prices.length,
  114. min: prices.length ? Math.min(...prices) : 0,
  115. max: prices.length ? Math.max(...prices) : 0,
  116. mean: prices.length ? prices.reduce((s, x) => s + x, 0) / prices.length : 0,
  117. median: median(prices),
  118. };
  119. // 价格带分布
  120. const priceBuckets = { '<$15': 0, '$15-25': 0, '$25-40': 0, '$40-60': 0, '$60-100': 0, '>$100': 0 };
  121. merged.forEach((p) => {
  122. const price = (p.SalesPrice || p.Price || 0) / 100;
  123. if (!price) return;
  124. if (price < 15) priceBuckets['<$15']++;
  125. else if (price < 25) priceBuckets['$15-25']++;
  126. else if (price < 40) priceBuckets['$25-40']++;
  127. else if (price < 60) priceBuckets['$40-60']++;
  128. else if (price < 100) priceBuckets['$60-100']++;
  129. else priceBuckets['>$100']++;
  130. });
  131. // 品牌份额(按月销量)
  132. const brandSales = {};
  133. let totalSales = 0;
  134. merged.forEach((p) => {
  135. if (!p.Brand) return;
  136. const sales = p.ListingSalesOfMonth || 0; // 月销售额(分)
  137. brandSales[p.Brand] = (brandSales[p.Brand] || 0) + sales;
  138. totalSales += sales;
  139. });
  140. const topBrands = Object.entries(brandSales)
  141. .sort((a, b) => b[1] - a[1])
  142. .slice(0, 10)
  143. .map(([brand, sales]) => ({
  144. brand,
  145. sales,
  146. share: totalSales > 0 ? (sales / totalSales * 100) : 0,
  147. count: merged.filter((p) => p.Brand === brand).length,
  148. }));
  149. // 市场集中度
  150. const top5Share = topBrands.slice(0, 5).reduce((s, b) => s + b.share, 0);
  151. const hhi = topBrands.reduce((s, b) => s + Math.pow(b.share, 2), 0);
  152. // 评分分布
  153. const ratingBuckets = { '< 4.0': 0, '4.0-4.3': 0, '4.3-4.5': 0, '4.5-4.7': 0, '≥ 4.7': 0 };
  154. const ratings = [];
  155. merged.forEach((p) => {
  156. const r = parseFloat(p.Ratings || 0);
  157. if (!r) return;
  158. ratings.push(r);
  159. if (r < 4.0) ratingBuckets['< 4.0']++;
  160. else if (r < 4.3) ratingBuckets['4.0-4.3']++;
  161. else if (r < 4.5) ratingBuckets['4.3-4.5']++;
  162. else if (r < 4.7) ratingBuckets['4.5-4.7']++;
  163. else ratingBuckets['≥ 4.7']++;
  164. });
  165. const avgRating = ratings.length ? ratings.reduce((s, x) => s + x, 0) / ratings.length : 0;
  166. const medianRating = median(ratings);
  167. // 评论数分布
  168. const reviewCountBuckets = { '< 100': 0, '100-500': 0, '500-1k': 0, '1k-5k': 0, '5k-20k': 0, '≥ 20k': 0 };
  169. merged.forEach((p) => {
  170. const c = p.RatingsCount || 0;
  171. if (c < 100) reviewCountBuckets['< 100']++;
  172. else if (c < 500) reviewCountBuckets['100-500']++;
  173. else if (c < 1000) reviewCountBuckets['500-1k']++;
  174. else if (c < 5000) reviewCountBuckets['1k-5k']++;
  175. else if (c < 20000) reviewCountBuckets['5k-20k']++;
  176. else reviewCountBuckets['≥ 20k']++;
  177. });
  178. // 销量汇总
  179. const totalMonthlySales = merged.reduce((s, p) => s + (p.ListingSalesVolumeOfMonth || 0), 0);
  180. const totalMonthlyRevenue = merged.reduce((s, p) => s + (p.ListingSalesOfMonth || 0), 0);
  181. // Top 产品(多维度)
  182. const topBySales = merged.slice(0, 10);
  183. const topByRating = merged.filter((p) => p.Ratings >= 4.5 && p.RatingsCount >= 500)
  184. .sort((a, b) => (b.RatingsCount || 0) - (a.RatingsCount || 0)).slice(0, 8);
  185. const topByReviews = merged.slice().sort((a, b) => (b.RatingsCount || 0) - (a.RatingsCount || 0)).slice(0, 8);
  186. // 详情数据聚合(有 ProductRequest 的产品)
  187. const detailedAsins = Object.keys(details);
  188. const detailProducts = detailedAsins.map((asin) => {
  189. const d = details[asin];
  190. const m = merged.find((p) => p.Asin === asin);
  191. return {
  192. asin,
  193. title: d.Title || m?.Title,
  194. brand: d.Brand || m?.Brand,
  195. price: d.SalesPrice || d.Price || m?.SalesPrice || m?.Price || 0,
  196. rating: d.Ratings || m?.Ratings,
  197. ratingsCount: d.RatingsCount || m?.RatingsCount,
  198. bsrRank: d.BsrRank,
  199. bsrCategory: d.BsrCategory?.[0]?.Name || d.Category?.[0]?.Name,
  200. buyboxSeller: d.BuyboxSeller,
  201. onlineDate: d.OnlineDate,
  202. onlineDays: d.OnlineDays,
  203. profit: d.Profit,
  204. profitRate: d.ProfitRate,
  205. photo: d.Photo?.[0] || d.Photo?.[0]?.Url,
  206. monthlySales: d.ListingSalesVolumeOfMonth || m?.ListingSalesVolumeOfMonth,
  207. monthlyRevenue: d.ListingSalesOfMonth || m?.ListingSalesOfMonth,
  208. hasVideo: d.HasVideo,
  209. aplus: d.APlus,
  210. brandStore: d.HasBrandStore,
  211. variantCount: d.VariationASINCount,
  212. bsrTrend: d.BsrRankTrend || [],
  213. };
  214. }).filter((p) => p.asin);
  215. // 评论摘要(仅抓到评论的产品)· Sorftime 返回数字索引对象 {0:{...},1:{...}}
  216. const reviewsSummary = {};
  217. const normalizeReviews = (rv) => {
  218. if (!rv) return [];
  219. if (Array.isArray(rv)) return rv;
  220. if (Array.isArray(rv.Reviews)) return rv.Reviews;
  221. if (Array.isArray(rv.reviews)) return rv.reviews;
  222. // Sorftime 原始:{0:{...},1:{...}} 格式
  223. return Object.values(rv).filter((x) =>
  224. x && typeof x === 'object' && (x.Star !== undefined || x.Rating !== undefined)
  225. );
  226. };
  227. const rating = (r) => parseFloat(r.Star ?? r.Rating ?? r.rating ?? 0);
  228. for (const asin of Object.keys(reviewsByAsin)) {
  229. const reviews = normalizeReviews(reviewsByAsin[asin]);
  230. const product = merged.find((p) => p.Asin === asin);
  231. if (!reviews.length) continue;
  232. const starDist = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
  233. reviews.forEach((r) => {
  234. const s = Math.round(rating(r));
  235. if (s >= 1 && s <= 5) starDist[s]++;
  236. });
  237. const positiveReviews = reviews.filter((r) => rating(r) >= 4);
  238. const negativeReviews = reviews.filter((r) => rating(r) <= 2);
  239. reviewsSummary[asin] = {
  240. asin,
  241. title: product?.Title?.slice(0, 60),
  242. brand: product?.Brand,
  243. total: reviews.length,
  244. starDist,
  245. positiveReviews: positiveReviews.slice(0, 5),
  246. negativeReviews: negativeReviews.slice(0, 5),
  247. averageRating: reviews.reduce((s, r) => s + rating(r), 0) / reviews.length,
  248. };
  249. }
  250. // 上市时间分布(新品 vs 老品)
  251. const onlineAges = detailProducts.map((p) => p.onlineDays).filter((x) => x > 0);
  252. const newProducts = detailProducts.filter((p) => (p.onlineDays || 0) < 180).length;
  253. const matureProducts = detailProducts.filter((p) => (p.onlineDays || 0) >= 365).length;
  254. // ========== 分析维度 1:英文 VOC 主题提取 ==========
  255. const amazonThemes = extractAmazonThemes(reviewsSummary);
  256. // ========== 分析维度 2:Top 5 竞品深度画像 ==========
  257. const competitorDossiers = buildCompetitorDossiers({
  258. topBrands, merged, detailProducts, reviewsSummary, avgRating, priceStats,
  259. });
  260. // ========== 分析维度 3:品类机会评分卡 ==========
  261. const opportunity = buildOpportunityScore({
  262. top5Share, hhi, priceStats, priceBuckets, avgRating, medianRating,
  263. ratingBuckets, reviewCountBuckets, total: merged.length,
  264. newProducts, matureProducts, avgOnlineDays: onlineAges.length ? Math.round(onlineAges.reduce((s, x) => s + x, 0) / onlineAges.length) : 0,
  265. });
  266. // ========== 分析维度 4:核心发现 + 战略建议 ==========
  267. const keyFindings = buildKeyFindings({
  268. top5Share, hhi, priceStats, avgRating, total: merged.length,
  269. newProducts, matureProducts, themes: amazonThemes,
  270. topBrand: topBrands[0],
  271. });
  272. const strategy = buildStrategyRecommendations({
  273. opportunity, themes: amazonThemes, priceStats, top5Share, avgRating,
  274. });
  275. return {
  276. empty: false,
  277. products: merged,
  278. total: merged.length,
  279. keywords: Object.keys(amz.by_keyword || {}),
  280. priceStats, priceBuckets,
  281. topBrands, top5Share, hhi,
  282. avgRating, medianRating, ratingBuckets,
  283. reviewCountBuckets,
  284. totalMonthlySales, totalMonthlyRevenue,
  285. topBySales, topByRating, topByReviews,
  286. detailProducts, reviewsSummary,
  287. newProducts, matureProducts, avgOnlineDays: onlineAges.length ? Math.round(onlineAges.reduce((s, x) => s + x, 0) / onlineAges.length) : 0,
  288. // 分析层(香薰报告式)
  289. amazonThemes,
  290. competitorDossiers,
  291. opportunity,
  292. keyFindings,
  293. strategy,
  294. };
  295. }
  296. // ============================================================
  297. // Amazon 英文 VOC 主题提取(保健食品品类专用)
  298. // ============================================================
  299. function extractAmazonThemes(reviewsSummary) {
  300. // 汇总所有正负评论文本
  301. const allReviews = [];
  302. Object.values(reviewsSummary || {}).forEach((r) => {
  303. (r.positiveReviews || []).forEach((x) => allReviews.push({
  304. text: String(x.Content || x.content || '').toLowerCase(),
  305. rating: parseFloat(x.Star ?? x.Rating ?? 5),
  306. brand: r.brand,
  307. asin: r.asin,
  308. }));
  309. (r.negativeReviews || []).forEach((x) => allReviews.push({
  310. text: String(x.Content || x.content || '').toLowerCase(),
  311. rating: parseFloat(x.Star ?? x.Rating ?? 1),
  312. brand: r.brand,
  313. asin: r.asin,
  314. }));
  315. });
  316. // 英文保健食品痛点词典(每组关键词 → 统一标签)
  317. const painPatterns = [
  318. { kw: ['side effect', 'stomach', 'nausea', 'headache', 'diarrhea', 'upset stomach', 'cramp'], tag: 'Side Effects · 副作用担忧', icon: '⚠️', emoji: '🩹' },
  319. { kw: ["doesn't work", 'not work', 'no effect', 'ineffective', 'no difference', 'waste of money'], tag: 'No Results · 效果无感', icon: '❌', emoji: '💸' },
  320. { kw: ['bad taste', 'tastes bad', 'hard to swallow', 'too big', 'smell bad', 'awful taste', 'chalky', 'bitter'], tag: 'Bad Taste/Form · 口感与剂型差', icon: '😖', emoji: '👅' },
  321. { kw: ['expensive', 'overpriced', 'not worth', 'too pricey', 'price is high', 'ripoff'], tag: 'Overpriced · 价格感知差', icon: '💰', emoji: '💵' },
  322. { kw: ['fake', 'counterfeit', 'not genuine', 'scam', 'suspicious', 'looks off'], tag: 'Authenticity Concern · 信任危机', icon: '🚨', emoji: '🔒' },
  323. { kw: ['arrived damaged', 'broken', 'leaking', 'crushed', 'seal broken', 'bottle damaged'], tag: 'Packaging Issues · 包装问题', icon: '📦', emoji: '📦' },
  324. { kw: ['small', 'low dose', 'few pills', 'only lasts', 'ran out', 'not enough'], tag: 'Small Quantity · 量少不划算', icon: '📉', emoji: '⏳' },
  325. ];
  326. const brightPatterns = [
  327. { kw: ['works great', 'really works', 'works well', 'effective', 'noticed a difference', 'noticeable', 'feel better'], tag: 'Effective · 效果认可', icon: '✅', emoji: '⭐' },
  328. { kw: ['easy to swallow', 'easy to take', 'no aftertaste', 'tastes good', 'tastes great', 'smooth'], tag: 'Easy to Take · 易服用', icon: '💊', emoji: '😊' },
  329. { kw: ['no side effect', 'gentle', 'no upset', 'easy on stomach', 'well tolerated'], tag: 'Gentle · 温和友好', icon: '🌿', emoji: '🫶' },
  330. { kw: ['good value', 'worth the price', 'great price', 'affordable', 'bang for buck', 'reasonable'], tag: 'Good Value · 性价比好', icon: '💲', emoji: '💰' },
  331. { kw: ['recommend', 'highly recommend', 'will buy again', 'repurchase', 'loyal customer', 'great product'], tag: 'Recommend/Repurchase · 推荐复购', icon: '❤️', emoji: '🔁' },
  332. { kw: ['high quality', 'premium', 'clean ingredients', 'natural', 'organic', 'non-gmo'], tag: 'Quality Ingredients · 优质原料', icon: '🏆', emoji: '🌱' },
  333. { kw: ['fast shipping', 'arrived quickly', 'well packed', 'packaging', 'secure'], tag: 'Good Logistics · 物流包装好', icon: '📦', emoji: '🚚' },
  334. ];
  335. const scenarioPatterns = [
  336. { kw: ['after drinking', 'hangover', 'party', 'alcohol', 'night out', 'social event'], tag: 'After Drinking · 应酬/宿醉' },
  337. { kw: ['daily routine', 'every morning', 'with breakfast', 'daily', 'supplement routine'], tag: 'Daily Routine · 日常养护' },
  338. { kw: ['travel', 'trip', 'vacation', 'on the go', 'busy'], tag: 'Travel/On-the-go · 差旅/便携' },
  339. { kw: ['doctor', 'physician', 'recommended by', 'medical', 'gi', 'ibs', 'gastroenterologist'], tag: 'Doctor-Recommended · 医生建议' },
  340. { kw: ['gift', 'christmas', 'birthday', 'for my', 'dad', 'mom', 'husband', 'wife'], tag: 'Gifting · 馈赠场景' },
  341. { kw: ['weight', 'diet', 'exercise', 'fitness', 'workout'], tag: 'Fitness/Diet · 健身饮食' },
  342. { kw: ['stress', 'anxiety', 'sleep', 'mood', 'energy', 'fatigue', 'tired'], tag: 'Stress/Energy · 情绪精力' },
  343. ];
  344. const classify = (patterns, reviews) => {
  345. const tagData = {};
  346. reviews.forEach((rv) => {
  347. for (const p of patterns) {
  348. if (p.kw.some((k) => rv.text.includes(k))) {
  349. if (!tagData[p.tag]) tagData[p.tag] = { tag: p.tag, icon: p.icon, emoji: p.emoji, count: 0, examples: [], brands: new Set() };
  350. tagData[p.tag].count++;
  351. if (tagData[p.tag].examples.length < 3 && rv.text.length < 400) {
  352. tagData[p.tag].examples.push({
  353. text: rv.text.length > 220 ? rv.text.slice(0, 220) + '...' : rv.text,
  354. rating: rv.rating,
  355. brand: rv.brand,
  356. asin: rv.asin,
  357. });
  358. }
  359. if (rv.brand) tagData[p.tag].brands.add(rv.brand);
  360. break;
  361. }
  362. }
  363. });
  364. return Object.values(tagData).map((d) => ({
  365. tag: d.tag, icon: d.icon, emoji: d.emoji, count: d.count,
  366. pct: reviews.length ? (d.count / reviews.length * 100) : 0,
  367. examples: d.examples,
  368. brandCoverage: d.brands.size,
  369. })).sort((a, b) => b.count - a.count);
  370. };
  371. const negReviews = allReviews.filter((r) => r.rating <= 3);
  372. const posReviews = allReviews.filter((r) => r.rating >= 4);
  373. return {
  374. totalReviews: allReviews.length,
  375. negReviews: negReviews.length,
  376. posReviews: posReviews.length,
  377. pain: classify(painPatterns, negReviews).slice(0, 6),
  378. bright: classify(brightPatterns, posReviews).slice(0, 6),
  379. scenarios: classify(scenarioPatterns, allReviews).slice(0, 6),
  380. };
  381. }
  382. // ============================================================
  383. // Top 5 竞品深度画像(含定位判断 / 优劣势分析 / 我们的对策)
  384. // ============================================================
  385. function buildCompetitorDossiers({ topBrands, merged, detailProducts, reviewsSummary, avgRating, priceStats }) {
  386. const top5 = topBrands.slice(0, 5);
  387. return top5.map((b) => {
  388. // 该品牌的所有产品
  389. const products = merged.filter((p) => p.Brand === b.brand);
  390. const brandPrices = products.map((p) => (p.SalesPrice || p.Price || 0) / 100).filter((x) => x > 0);
  391. const brandRatings = products.map((p) => parseFloat(p.Ratings || 0)).filter((x) => x > 0);
  392. const totalReviews = products.reduce((s, p) => s + (p.RatingsCount || 0), 0);
  393. const topProduct = products.slice().sort((a, b) => (b.ListingSalesVolumeOfMonth || 0) - (a.ListingSalesVolumeOfMonth || 0))[0];
  394. const detail = detailProducts.find((d) => d.brand === b.brand);
  395. const avgPrice = brandPrices.length ? brandPrices.reduce((s, x) => s + x, 0) / brandPrices.length : 0;
  396. const avgRt = brandRatings.length ? brandRatings.reduce((s, x) => s + x, 0) / brandRatings.length : 0;
  397. // 定位判断
  398. const priceVsCategory = avgPrice - priceStats.mean;
  399. const ratingVsCategory = avgRt - avgRating;
  400. let positioning, positionColor;
  401. if (b.share >= 25 && avgRt >= avgRating) {
  402. positioning = 'Category Leader · 类目领导者';
  403. positionColor = 'amber';
  404. } else if (priceVsCategory > 5 && avgRt >= 4.3) {
  405. positioning = 'Premium Player · 高端玩家';
  406. positionColor = 'purple';
  407. } else if (priceVsCategory < -3 && b.share >= 5) {
  408. positioning = 'Value Challenger · 性价比挑战者';
  409. positionColor = 'green';
  410. } else if (totalReviews >= 10000 && avgRt >= 4.3) {
  411. positioning = 'Volume Established · 口碑巨鲸';
  412. positionColor = 'blue';
  413. } else if ((detail?.onlineDays || 0) < 365 && avgRt >= 4.3) {
  414. positioning = 'Rising Star · 成长新秀';
  415. positionColor = 'rose';
  416. } else if (avgRt < avgRating - 0.2) {
  417. positioning = 'Weak Link · 口碑软肋';
  418. positionColor = 'rose';
  419. } else {
  420. positioning = 'Niche Specialist · 垂类玩家';
  421. positionColor = 'blue';
  422. }
  423. // 聚合该品牌所有评论(跨 ASIN)的关键词
  424. const brandAsins = products.map((p) => p.Asin);
  425. const brandReviews = [];
  426. brandAsins.forEach((a) => {
  427. const r = reviewsSummary[a];
  428. if (!r) return;
  429. brandReviews.push(...(r.positiveReviews || []).map((x) => ({ text: String(x.Content || x.content || '').toLowerCase(), kind: 'pos' })));
  430. brandReviews.push(...(r.negativeReviews || []).map((x) => ({ text: String(x.Content || x.content || '').toLowerCase(), kind: 'neg' })));
  431. });
  432. // 提炼优势 / 劣势(基于关键词频次)
  433. const scoreKw = (reviews, list) => {
  434. const out = {};
  435. reviews.forEach((rv) => {
  436. list.forEach((item) => {
  437. if (item.kw.some((k) => rv.text.includes(k))) {
  438. out[item.label] = (out[item.label] || 0) + 1;
  439. }
  440. });
  441. });
  442. return Object.entries(out).sort((a, b) => b[1] - a[1]).slice(0, 3).map(([label, n]) => ({ label, n }));
  443. };
  444. const strengths = scoreKw(brandReviews.filter((r) => r.kind === 'pos'), [
  445. { kw: ['works', 'effective', 'noticed'], label: '效果被认可' },
  446. { kw: ['easy to swallow', 'easy to take', 'no aftertaste'], label: '服用体验好' },
  447. { kw: ['value', 'affordable', 'worth'], label: '性价比突出' },
  448. { kw: ['recommend', 'buy again', 'repurchase'], label: '高复购意愿' },
  449. { kw: ['quality', 'premium', 'natural', 'organic'], label: '原料/工艺优势' },
  450. { kw: ['gentle', 'no side'], label: '温和无副作用' },
  451. ]);
  452. const weaknesses = scoreKw(brandReviews.filter((r) => r.kind === 'neg'), [
  453. { kw: ['side effect', 'stomach', 'headache'], label: '副作用投诉' },
  454. { kw: ["doesn't work", 'no effect', 'waste'], label: '效果受质疑' },
  455. { kw: ['bad taste', 'smell', 'hard to swallow', 'chalky'], label: '口感/剂型差' },
  456. { kw: ['expensive', 'overpriced', 'ripoff'], label: '价格争议' },
  457. { kw: ['fake', 'counterfeit', 'scam'], label: '真伪投诉' },
  458. { kw: ['damaged', 'leaking', 'broken seal'], label: '物流损坏' },
  459. { kw: ['small', 'few', 'ran out'], label: '量少争议' },
  460. ]);
  461. return {
  462. brand: b.brand,
  463. share: b.share,
  464. sales: b.sales,
  465. skuCount: b.count,
  466. totalReviews,
  467. avgPrice,
  468. avgRating: avgRt,
  469. topProduct: topProduct ? {
  470. asin: topProduct.Asin, title: topProduct.Title,
  471. price: (topProduct.SalesPrice || topProduct.Price || 0) / 100,
  472. rating: parseFloat(topProduct.Ratings || 0),
  473. reviewCount: topProduct.RatingsCount || 0,
  474. monthlySales: topProduct.ListingSalesVolumeOfMonth || 0,
  475. } : null,
  476. detail,
  477. positioning, positionColor,
  478. strengths, weaknesses,
  479. priceVsCategory: +priceVsCategory.toFixed(1),
  480. ratingVsCategory: +ratingVsCategory.toFixed(2),
  481. };
  482. });
  483. }
  484. // ============================================================
  485. // 品类机会评分卡(4 维打分 + 综合等级)
  486. // ============================================================
  487. function buildOpportunityScore({
  488. top5Share, hhi, priceStats, priceBuckets, avgRating, medianRating,
  489. ratingBuckets, reviewCountBuckets, total, newProducts, matureProducts,
  490. }) {
  491. // === 维度 1:市场集中度(越分散越好进入)===
  492. let concLevel, concScore, concNote;
  493. if (top5Share < 35) { concLevel = 'FRAGMENTED'; concScore = 5; concNote = '前5品牌仅占 ' + top5Share.toFixed(1) + '%,市场高度分散,**新玩家进入门槛低**'; }
  494. else if (top5Share < 55) { concLevel = 'MODERATE'; concScore = 3; concNote = '前5品牌占 ' + top5Share.toFixed(1) + '%,**需差异化定位**才能切入'; }
  495. else { concLevel = 'CONCENTRATED'; concScore = 1; concNote = '前5品牌垄断 ' + top5Share.toFixed(1) + '%(HHI=' + Math.round(hhi) + '),**正面硬拼不可取**'; }
  496. // === 维度 2:价格进入难度 ===
  497. const priceSpread = priceStats.max - priceStats.min;
  498. let priceLevel, priceScore, priceNote, sweetSpot;
  499. const sortedBuckets = Object.entries(priceBuckets).sort((a, b) => b[1] - a[1]);
  500. sweetSpot = sortedBuckets[0]?.[0] || '$15-25';
  501. if (priceSpread > 40) { priceLevel = 'WIDE RANGE'; priceScore = 4; priceNote = '价格跨度 $' + priceStats.min.toFixed(0) + '-$' + priceStats.max.toFixed(0) + ',各带均有生存空间。甜点价位 **' + sweetSpot + '** (' + sortedBuckets[0][1] + ' 款)'; }
  502. else if (priceSpread > 20) { priceLevel = 'BALANCED'; priceScore = 3; priceNote = '价格带分布合理,主流集中在 **' + sweetSpot + '** (' + sortedBuckets[0][1] + ' 款,占 ' + ((sortedBuckets[0][1] / total * 100).toFixed(1)) + '%)'; }
  503. else { priceLevel = 'NARROW'; priceScore = 2; priceNote = '价格高度集中在 ' + sweetSpot + ',**错位定价机会少**'; }
  504. // === 维度 3:质量门槛(基于评分分布)===
  505. const highRatingCount = (ratingBuckets['≥ 4.7'] || 0) + (ratingBuckets['4.5-4.7'] || 0);
  506. const highRatingPct = total ? (highRatingCount / total * 100) : 0;
  507. let qualityLevel, qualityScore, qualityNote;
  508. if (avgRating >= 4.5 && highRatingPct >= 60) { qualityLevel = 'HIGH BAR'; qualityScore = 2; qualityNote = '行业均评 ' + avgRating.toFixed(2) + ',' + highRatingPct.toFixed(0) + '% 产品 ≥4.5 分,**质量门槛极高**,需做到 4.6+ 才有竞争力'; }
  509. else if (avgRating >= 4.3) { qualityLevel = 'STANDARD'; qualityScore = 4; qualityNote = '均评 ' + avgRating.toFixed(2) + ',**质量及格线清晰**,做到 4.5+ 可拉开差距'; }
  510. else { qualityLevel = 'LOW BAR'; qualityScore = 5; qualityNote = '均评仅 ' + avgRating.toFixed(2) + ',**质量分化大**,做到 4.5+ 即可脱颖而出'; }
  511. // === 维度 4:评论沉淀壁垒 ===
  512. const highReviewCount = (reviewCountBuckets['≥ 20k'] || 0) + (reviewCountBuckets['5k-20k'] || 0) + (reviewCountBuckets['1k-5k'] || 0);
  513. const lowReviewCount = (reviewCountBuckets['< 100'] || 0) + (reviewCountBuckets['100-500'] || 0);
  514. let reviewLevel, reviewScore, reviewNote;
  515. if (highReviewCount / total > 0.5) { reviewLevel = 'DEEP MOAT'; reviewScore = 2; reviewNote = '过半产品评论 ≥1k,**口碑护城河极深**,新品冷启动周期长'; }
  516. else if (highReviewCount / total > 0.3) { reviewLevel = 'MODERATE MOAT'; reviewScore = 3; reviewNote = highReviewCount + ' 款产品评论 ≥1k(' + ((highReviewCount / total * 100).toFixed(0)) + '%),**老品有口碑壁垒但不算高不可攀**'; }
  517. else { reviewLevel = 'LOW MOAT'; reviewScore = 5; reviewNote = lowReviewCount + ' 款产品评论 <500(' + ((lowReviewCount / total * 100).toFixed(0)) + '%),**评论壁垒低,新品有快速起量空间**'; }
  518. // === 维度 5:增长信号(新品比例)===
  519. const totalDetailed = Math.max(1, newProducts + matureProducts);
  520. const newRatio = (newProducts / totalDetailed) * 100;
  521. let growthLevel, growthScore, growthNote;
  522. if (newRatio >= 40) { growthLevel = 'ACCELERATING'; growthScore = 5; growthNote = '详情样本中新品占 ' + newRatio.toFixed(0) + '%(<180d),**品类在快速扩容**'; }
  523. else if (newRatio >= 20) { growthLevel = 'STEADY'; growthScore = 3; growthNote = '新品占 ' + newRatio.toFixed(0) + '%,**品类稳态**,老品与新品并存'; }
  524. else { growthLevel = 'MATURE'; growthScore = 2; growthNote = '新品仅占 ' + newRatio.toFixed(0) + '%,**品类成熟**,迭代节奏慢'; }
  525. const totalScore = concScore + priceScore + qualityScore + reviewScore + growthScore;
  526. const maxScore = 5 * 5;
  527. const grade = totalScore / maxScore >= 0.75 ? 'A · 强烈推荐'
  528. : totalScore / maxScore >= 0.6 ? 'B · 推荐'
  529. : totalScore / maxScore >= 0.45 ? 'C · 可选'
  530. : 'D · 需谨慎';
  531. const gradeColor = totalScore / maxScore >= 0.75 ? 'green'
  532. : totalScore / maxScore >= 0.6 ? 'amber'
  533. : totalScore / maxScore >= 0.45 ? 'blue'
  534. : 'rose';
  535. return {
  536. totalScore, maxScore, grade, gradeColor,
  537. sweetSpot,
  538. dimensions: [
  539. { name: '市场集中度', level: concLevel, score: concScore, note: concNote, icon: '🎯' },
  540. { name: '价格切入', level: priceLevel, score: priceScore, note: priceNote, icon: '💲' },
  541. { name: '质量门槛', level: qualityLevel, score: qualityScore, note: qualityNote, icon: '⭐' },
  542. { name: '评论壁垒', level: reviewLevel, score: reviewScore, note: reviewNote, icon: '💬' },
  543. { name: '增长信号', level: growthLevel, score: growthScore, note: growthNote, icon: '📈' },
  544. ],
  545. };
  546. }
  547. // ============================================================
  548. // 核心发现(3-4 条可直接用于 Executive Summary 的判断)
  549. // ============================================================
  550. function buildKeyFindings({ top5Share, hhi, priceStats, avgRating, total, newProducts, matureProducts, themes, topBrand }) {
  551. const findings = [];
  552. // Finding 1:结构
  553. findings.push({
  554. icon: top5Share < 40 ? '✅' : top5Share < 60 ? '⚠️' : '❌',
  555. title: top5Share < 40 ? '市场分散可进入' : top5Share < 60 ? '中度集中需差异化' : '寡头垄断需谨慎',
  556. body: '前5品牌占 <strong>' + top5Share.toFixed(1) + '%</strong>(HHI=' + Math.round(hhi) + ')'
  557. + (topBrand ? ',龙头 <strong>' + topBrand.brand + '</strong> 独占 ' + topBrand.share.toFixed(1) + '%' : '')
  558. + '。' + (top5Share < 40 ? '新玩家有窗口,关键是找差异化钩子。' : top5Share < 60 ? '硬拼难赢,建议从头部未覆盖的痛点切入。' : '建议做补位者而非挑战者。'),
  559. tone: top5Share < 40 ? 'green' : top5Share < 60 ? 'amber' : 'rose',
  560. });
  561. // Finding 2:价格
  562. findings.push({
  563. icon: '💲',
  564. title: '定价锚点 $' + priceStats.median.toFixed(1) + ',建议价位 $' + (Math.round(priceStats.median * 0.9) + '-' + Math.round(priceStats.median * 1.2)),
  565. body: '行业均价 $' + priceStats.mean.toFixed(1) + ',中位数 $' + priceStats.median.toFixed(1) + '($' + priceStats.min.toFixed(0) + '-$' + priceStats.max.toFixed(0) + ')。'
  566. + '消费者对此品类的价格期望已形成锚定,<strong>新品建议贴近中位数 ±15%</strong>,过高需强差异化支撑,过低会被误判为低质。',
  567. tone: 'blue',
  568. });
  569. // Finding 3:质量门槛
  570. findings.push({
  571. icon: avgRating >= 4.5 ? '🏆' : avgRating >= 4.3 ? '⭐' : '⚠️',
  572. title: '质量基线 ' + avgRating.toFixed(2) + ' 星 · ' + (avgRating >= 4.5 ? '高' : avgRating >= 4.3 ? '标准' : '偏低'),
  573. body: '行业均评 <strong>' + avgRating.toFixed(2) + '</strong>。'
  574. + (avgRating >= 4.5 ? '这是一个质量门槛极高的品类,新品不做到 4.6+ 很难撑起单品。' : avgRating >= 4.3 ? '做到 4.5+ 即可拉开差距。' : '口碑分化明显,有质量红利可吃。')
  575. + (themes.pain[0] ? '最高频痛点是 <strong>' + themes.pain[0].tag + '</strong>(' + themes.pain[0].count + ' 次提及),这是第一优化方向。' : ''),
  576. tone: avgRating >= 4.5 ? 'rose' : avgRating >= 4.3 ? 'amber' : 'green',
  577. });
  578. // Finding 4:用户原声主线
  579. if (themes.bright[0] && themes.pain[0]) {
  580. findings.push({
  581. icon: '🗣️',
  582. title: '用户原声:「' + (themes.bright[0].tag.split(' · ')[1] || themes.bright[0].tag) + '」是第一加分项',
  583. body: '好评 Top 1:<strong>' + themes.bright[0].tag + '</strong>(' + themes.bright[0].count + ' 次,覆盖 ' + themes.bright[0].brandCoverage + ' 家品牌)'
  584. + ';差评 Top 1:<strong>' + themes.pain[0].tag + '</strong>(' + themes.pain[0].count + ' 次)。'
  585. + '营销应将「' + (themes.bright[0].tag.split(' · ')[1] || themes.bright[0].tag) + '」作为 hero claim,产品端重点规避「' + (themes.pain[0].tag.split(' · ')[1] || themes.pain[0].tag) + '」。',
  586. tone: 'purple',
  587. });
  588. }
  589. return findings;
  590. }
  591. // ============================================================
  592. // 战略建议(短/中/长期 3 轨)
  593. // ============================================================
  594. function buildStrategyRecommendations({ opportunity, themes, priceStats, top5Share, avgRating }) {
  595. const sweetPrice = '$' + Math.round(priceStats.median * 0.9) + '-$' + Math.round(priceStats.median * 1.2);
  596. const topPain = themes.pain[0];
  597. const topBright = themes.bright[0];
  598. const topScene = themes.scenarios[0];
  599. return {
  600. short: {
  601. title: '0-30 天 · 选品与立项',
  602. badge: 'QUICK WIN',
  603. items: [
  604. '定价锚点 <strong>' + sweetPrice + '</strong>(贴近行业中位数 ±15%),避免"高不成低不就"',
  605. topPain ? '产品 PRD 首要规避 <strong>' + (topPain.tag.split(' · ')[1] || topPain.tag) + '</strong>(差评 Top1, ' + topPain.count + ' 次提及)' : '基于品类痛点做产品 PRD 差异化',
  606. '参考 ' + opportunity.sweetSpot + ' 价位 Top 3 款做 listing 结构逆向拆解(标题/图/A+内容)',
  607. ],
  608. },
  609. mid: {
  610. title: '30-90 天 · 冷启动与放量',
  611. badge: 'SCALE',
  612. items: [
  613. topBright ? '营销 hero claim 直接挪用高频好评词 <strong>「' + (topBright.tag.split(' · ')[1] || topBright.tag) + '」</strong>(' + topBright.count + ' 次提及)' : '营销 hero claim 基于品类好评共识',
  614. topScene ? '种草场景聚焦 <strong>' + (topScene.tag.split(' · ')[1] || topScene.tag) + '</strong>(' + topScene.count + ' 次用户自发提及)' : '种草场景聚焦用户自述的高频使用时刻',
  615. avgRating >= 4.5 ? '保持 ≥4.6 评分是生死线,Review seeding + Vine 必须做满 50 条' : '保持 ≥4.5 即可拉开差距,Review seeding 20-30 条足够起量',
  616. top5Share >= 55 ? '不正面硬拼头部品牌,用长尾关键词 ASIN Targeting 抢流量' : '可直接 Brand Defense + Category ASIN Targeting 双线抢位',
  617. ],
  618. },
  619. long: {
  620. title: '90 天+ · 沉淀与破圈',
  621. badge: 'MOAT',
  622. items: [
  623. '<strong>Review 护城河</strong>:6 个月内积累 ≥1000 条真评,进入评论壁垒俱乐部',
  624. '<strong>Bundle 策略</strong>:基于用户多场景(' + themes.scenarios.slice(0, 2).map((s) => s.tag.split(' · ')[1] || s.tag).join(' / ') + ')做跨场景组合装',
  625. '<strong>站外破圈</strong>:把好评词作为 TikTok / Reddit UGC 素材源,低成本撬动信任',
  626. opportunity.grade.startsWith('A') ? '<strong>机会等级 A</strong>:建议 6 个月内 ≥3 SKU 系列化铺开' : '<strong>机会等级 ' + opportunity.grade.split(' ')[0] + '</strong>:先打磨单 SKU 做深,12 个月后再考虑扩线',
  627. ],
  628. },
  629. };
  630. }
  631. // ============================================================
  632. // 抖音分析
  633. // ============================================================
  634. function analyzeDouyin(voc) {
  635. const dy = voc.douyin;
  636. if (!dy || dy._pending_cookie) {
  637. return {
  638. empty: true,
  639. pending_cookie: dy?._pending_cookie || false,
  640. share_url: dy?.share_url,
  641. error: dy?._error || dy?._resolve_error,
  642. };
  643. }
  644. const video = dy.video;
  645. const comments = dy.comments || [];
  646. const userPosts = dy.user_posts || [];
  647. const userProfile = dy.user_profile;
  648. const searches = dy.searches || {};
  649. // 评论按点赞排序
  650. const topComments = comments.slice()
  651. .filter((c) => c.text && c.text.length >= 5 && c.text.length <= 200)
  652. .sort((a, b) => (b.digg_count || 0) - (a.digg_count || 0))
  653. .slice(0, 20);
  654. // 评论 IP 分布
  655. const ipDist = {};
  656. comments.forEach((c) => { if (c.ip_label) ipDist[c.ip_label] = (ipDist[c.ip_label] || 0) + 1; });
  657. const topIPs = Object.entries(ipDist).sort((a, b) => b[1] - a[1]).slice(0, 8);
  658. // 作者作品互动均值
  659. const authorStats = userPosts.length ? {
  660. count: userPosts.length,
  661. avgDigg: userPosts.reduce((s, v) => s + (v.statistics?.digg_count || 0), 0) / userPosts.length,
  662. avgComment: userPosts.reduce((s, v) => s + (v.statistics?.comment_count || 0), 0) / userPosts.length,
  663. avgPlay: userPosts.reduce((s, v) => s + (v.statistics?.play_count || 0), 0) / userPosts.length,
  664. } : null;
  665. // 品类搜索覆盖
  666. const searchSummary = {};
  667. for (const kw of Object.keys(searches)) {
  668. const videos = Array.isArray(searches[kw]) ? searches[kw] : [];
  669. searchSummary[kw] = {
  670. count: videos.length,
  671. totalDigg: videos.reduce((s, v) => s + (v.statistics?.digg_count || 0), 0),
  672. totalComment: videos.reduce((s, v) => s + (v.statistics?.comment_count || 0), 0),
  673. top: videos.slice(0, 5).map((v) => ({
  674. aweme_id: v.aweme_id,
  675. desc: v.desc,
  676. digg: v.statistics?.digg_count,
  677. comment: v.statistics?.comment_count,
  678. author: v.author?.nickname,
  679. authorFans: v.author?.follower_count,
  680. cover: v.cover,
  681. })),
  682. };
  683. }
  684. // 作品 Top 8(按点赞)
  685. const topPosts = userPosts.slice()
  686. .sort((a, b) => (b.statistics?.digg_count || 0) - (a.statistics?.digg_count || 0))
  687. .slice(0, 8)
  688. .map((v) => ({
  689. aweme_id: v.aweme_id,
  690. desc: v.desc,
  691. cover: v.cover,
  692. create_time: v.create_time,
  693. digg: v.statistics?.digg_count || 0,
  694. comment: v.statistics?.comment_count || 0,
  695. share: v.statistics?.share_count || 0,
  696. play: v.statistics?.play_count || 0,
  697. collect: v.statistics?.collect_count || 0,
  698. }));
  699. // 评论按 aweme_id 分组
  700. const commentsByPost = {};
  701. comments.forEach((c) => {
  702. const pid = c.aweme_id || (video?.aweme_id);
  703. if (!pid) return;
  704. if (!commentsByPost[pid]) commentsByPost[pid] = [];
  705. commentsByPost[pid].push(c);
  706. });
  707. // === 多账号聚合(如果 dy.accounts 存在)===
  708. const accounts = Array.isArray(dy.accounts) ? dy.accounts : [];
  709. const accountCards = accounts.map((acc) => {
  710. const ap = acc.user_posts || [];
  711. const profile = acc.user_profile || {};
  712. const topDigg = ap.reduce((s, v) => s + (v.statistics?.digg_count || 0), 0);
  713. const topComment = ap.reduce((s, v) => s + (v.statistics?.comment_count || 0), 0);
  714. const topPlay = ap.reduce((s, v) => s + (v.statistics?.play_count || 0), 0);
  715. const best = [...ap].sort((a, b) => (b.statistics?.digg_count || 0) - (a.statistics?.digg_count || 0))[0];
  716. return {
  717. role: acc.role || '官方号',
  718. label: acc.label || acc.role || '账号',
  719. share_url: acc.share_url,
  720. share_type: acc.share_type,
  721. shareType: acc.share_type,
  722. nickname: profile.nickname || '—',
  723. signature: (profile.signature || '').replace(/\n/g, ' '),
  724. avatar: profile.avatar,
  725. custom_verify: profile.custom_verify,
  726. follower_count: profile.follower_count || 0,
  727. following_count: profile.following_count || 0,
  728. aweme_count: profile.aweme_count || 0,
  729. total_favorited: profile.total_favorited || 0,
  730. posts_sampled: ap.length,
  731. sum_digg: topDigg,
  732. sum_comment: topComment,
  733. sum_play: topPlay,
  734. top_post: best ? {
  735. aweme_id: best.aweme_id,
  736. desc: (best.desc || '').replace(/\n/g, ' '),
  737. digg: best.statistics?.digg_count || 0,
  738. comment: best.statistics?.comment_count || 0,
  739. play: best.statistics?.play_count || 0,
  740. cover: best.cover,
  741. } : null,
  742. top_posts: [...ap]
  743. .sort((a, b) => (b.statistics?.digg_count || 0) - (a.statistics?.digg_count || 0))
  744. .slice(0, 4)
  745. .map((v) => ({
  746. aweme_id: v.aweme_id,
  747. desc: (v.desc || '').replace(/\n/g, ' ').slice(0, 80),
  748. digg: v.statistics?.digg_count || 0,
  749. comment: v.statistics?.comment_count || 0,
  750. play: v.statistics?.play_count || 0,
  751. cover: v.cover,
  752. })),
  753. comments_count: (acc.comments || []).length,
  754. error: acc._resolve_error || acc._error,
  755. };
  756. });
  757. // 跨账号汇总 metrics
  758. const aggMetrics = accountCards.length ? {
  759. total_accounts: accountCards.length,
  760. total_followers: accountCards.reduce((s, c) => s + (c.follower_count || 0), 0),
  761. total_posts_sampled: accountCards.reduce((s, c) => s + (c.posts_sampled || 0), 0),
  762. total_digg: accountCards.reduce((s, c) => s + (c.sum_digg || 0), 0),
  763. total_comments: accountCards.reduce((s, c) => s + (c.comments_count || 0), 0),
  764. verified_count: accountCards.filter((c) => c.custom_verify && c.custom_verify.length).length,
  765. } : null;
  766. return {
  767. empty: !video && !userProfile && userPosts.length === 0 && comments.length === 0,
  768. shareType: dy.share_type || (video ? 'video' : 'user'),
  769. video, comments, userPosts, userProfile, searches: searchSummary,
  770. topComments, topIPs, authorStats, topPosts, commentsByPost,
  771. commentsCount: comments.length,
  772. // 新增:多账号聚合(向下兼容,老渲染器忽略即可)
  773. accountCards,
  774. aggMetrics,
  775. };
  776. }
  777. // ============================================================
  778. // TikTok 分析(Amazon 的内容端对标)
  779. // ============================================================
  780. function analyzeTikTok(voc) {
  781. const tt = voc.tiktok;
  782. if (!tt || tt._error) {
  783. return { empty: true, error: tt?._error };
  784. }
  785. const videos = tt.merged_videos || [];
  786. if (!videos.length) {
  787. return { empty: true, keywords_used: tt.keywords_used || [] };
  788. }
  789. // Hero metrics
  790. const totalVideos = videos.length;
  791. const totalDigg = videos.reduce((s, v) => s + (v.statistics?.digg_count || 0), 0);
  792. const totalPlay = videos.reduce((s, v) => s + (v.statistics?.play_count || 0), 0);
  793. const totalComment = videos.reduce((s, v) => s + (v.statistics?.comment_count || 0), 0);
  794. const totalShare = videos.reduce((s, v) => s + (v.statistics?.share_count || 0), 0);
  795. const totalCollect = videos.reduce((s, v) => s + (v.statistics?.collect_count || 0), 0);
  796. const authorSet = new Set(videos.map((v) => v.author?.sec_uid).filter(Boolean));
  797. const verifiedAuthors = videos.filter((v) => v.author?.custom_verify).length;
  798. const verifiedRatio = totalVideos ? verifiedAuthors / totalVideos : 0;
  799. const totalFollowersReached = [...authorSet].reduce((s, uid) => {
  800. const v = videos.find((x) => x.author?.sec_uid === uid);
  801. return s + (v?.author?.follower_count || 0);
  802. }, 0);
  803. // 每关键词统计
  804. const byKeywordStats = {};
  805. for (const [kw, bkt] of Object.entries(tt.by_keyword || {})) {
  806. const vs = bkt?.videos || [];
  807. byKeywordStats[kw] = {
  808. videos: vs.length,
  809. totalDigg: vs.reduce((s, v) => s + (v.statistics?.digg_count || 0), 0),
  810. totalComment: vs.reduce((s, v) => s + (v.statistics?.comment_count || 0), 0),
  811. totalPlay: vs.reduce((s, v) => s + (v.statistics?.play_count || 0), 0),
  812. avgDigg: vs.length ? Math.round(vs.reduce((s, v) => s + (v.statistics?.digg_count || 0), 0) / vs.length) : 0,
  813. };
  814. }
  815. // Top 视频(按赞)
  816. const topVideos = [...videos]
  817. .sort((a, b) => (b.statistics?.digg_count || 0) - (a.statistics?.digg_count || 0))
  818. .slice(0, 12)
  819. .map((v) => ({
  820. aweme_id: v.aweme_id,
  821. desc: (v.desc || '').replace(/\n/g, ' ').slice(0, 180),
  822. cover: v.cover,
  823. digg: v.statistics?.digg_count || 0,
  824. comment: v.statistics?.comment_count || 0,
  825. play: v.statistics?.play_count || 0,
  826. share: v.statistics?.share_count || 0,
  827. collect: v.statistics?.collect_count || 0,
  828. author: v.author ? {
  829. nickname: v.author.nickname,
  830. unique_id: v.author.unique_id,
  831. follower_count: v.author.follower_count,
  832. custom_verify: v.author.custom_verify,
  833. avatar: v.author.avatar,
  834. } : null,
  835. hashtags: v.hashtags || [],
  836. create_time: v.create_time,
  837. region: v.region,
  838. _from_keyword: v._from_keyword,
  839. }));
  840. // 高赞播放比(engagement rate)
  841. const videosWithRate = videos.map((v) => ({
  842. ...v,
  843. _er: v.statistics?.play_count > 0
  844. ? ((v.statistics.digg_count || 0) / v.statistics.play_count)
  845. : 0,
  846. }));
  847. const avgEngagementRate = videosWithRate.length
  848. ? (videosWithRate.reduce((s, v) => s + v._er, 0) / videosWithRate.length)
  849. : 0;
  850. // Hashtag 频率
  851. const hashtagCount = {};
  852. videos.forEach((v) => {
  853. (v.hashtags || []).forEach((h) => {
  854. const tag = h.toLowerCase().trim();
  855. if (tag) hashtagCount[tag] = (hashtagCount[tag] || 0) + 1;
  856. });
  857. });
  858. const topHashtags = Object.entries(hashtagCount)
  859. .sort((a, b) => b[1] - a[1])
  860. .slice(0, 20)
  861. .map(([tag, count]) => ({ tag, count }));
  862. // 地区分布
  863. const regionCount = {};
  864. videos.forEach((v) => {
  865. const r = v.region || 'Unknown';
  866. regionCount[r] = (regionCount[r] || 0) + 1;
  867. });
  868. const topRegions = Object.entries(regionCount).sort((a, b) => b[1] - a[1]).slice(0, 8);
  869. // Top 作者卡(结合 author_profiles)
  870. const topAuthors = (tt.top_authors || []).slice(0, 5).map((a) => {
  871. const profile = tt.author_profiles?.[a.sec_uid] || a;
  872. const posts = tt.author_posts?.[a.sec_uid] || [];
  873. const bestPost = [...posts].sort((x, y) => (y.statistics?.digg_count || 0) - (x.statistics?.digg_count || 0))[0];
  874. return {
  875. sec_uid: a.sec_uid,
  876. unique_id: profile.unique_id || a.unique_id,
  877. nickname: profile.nickname || a.nickname,
  878. follower_count: profile.follower_count || a.follower_count || 0,
  879. total_favorited: profile.total_favorited || a.total_favorited || 0,
  880. aweme_count: profile.aweme_count || a.aweme_count || 0,
  881. custom_verify: profile.custom_verify || a.custom_verify,
  882. signature: (profile.signature || '').replace(/\n/g, ' ').slice(0, 120),
  883. region: profile.region,
  884. avatar: profile.avatar || a.avatar,
  885. sampled_posts: posts.length,
  886. best_post: bestPost ? {
  887. aweme_id: bestPost.aweme_id,
  888. desc: (bestPost.desc || '').replace(/\n/g, ' ').slice(0, 100),
  889. digg: bestPost.statistics?.digg_count || 0,
  890. play: bestPost.statistics?.play_count || 0,
  891. cover: bestPost.cover,
  892. } : null,
  893. };
  894. });
  895. // Top 评论
  896. const allComments = [];
  897. for (const [aid, cms] of Object.entries(tt.top_comments_by_video || {})) {
  898. (cms || []).forEach((c) => allComments.push({ ...c, aweme_id: aid }));
  899. }
  900. const topComments = allComments
  901. .filter((c) => c.text && c.text.length >= 5 && c.text.length <= 280)
  902. .sort((a, b) => (b.digg_count || 0) - (a.digg_count || 0))
  903. .slice(0, 15);
  904. // 品类官方/头部账号(user search 的结果)
  905. const catUsers = [];
  906. for (const users of Object.values(tt.category_users || {})) {
  907. (users || []).forEach((u) => {
  908. if (!catUsers.find((x) => x.sec_uid === u.sec_uid)) catUsers.push(u);
  909. });
  910. }
  911. const topCatUsers = catUsers
  912. .sort((a, b) => (b.follower_count || 0) - (a.follower_count || 0))
  913. .slice(0, 6);
  914. return {
  915. empty: false,
  916. keywords_used: tt.keywords_used || [],
  917. metrics: {
  918. totalVideos,
  919. totalDigg,
  920. totalPlay,
  921. totalComment,
  922. totalShare,
  923. totalCollect,
  924. totalAuthors: authorSet.size,
  925. verifiedAuthors,
  926. verifiedRatio,
  927. totalFollowersReached,
  928. avgEngagementRate,
  929. },
  930. byKeywordStats,
  931. topVideos,
  932. topHashtags,
  933. topRegions,
  934. topAuthors,
  935. topComments,
  936. topCategoryUsers: topCatUsers,
  937. };
  938. }
  939. // ============================================================
  940. // 主入口:聚合所有分析
  941. // ============================================================
  942. function analyze(voc) {
  943. const xhs = analyzeXhs(voc);
  944. const amazon = analyzeAmazon(voc);
  945. const douyin = analyzeDouyin(voc);
  946. const tiktok = analyzeTikTok(voc);
  947. // 兼容旧字段(让旧渲染器不挂)
  948. return {
  949. meta: voc,
  950. metrics: xhs.metrics,
  951. topNotes: xhs.topNotes,
  952. topComments: xhs.topComments,
  953. kols: xhs.kols,
  954. kolTier: xhs.kolTier,
  955. topIPs: xhs.topIPs,
  956. // Amazon 新接口(丰富维度)
  957. amazon,
  958. // 兼容旧字段
  959. amazonProducts: amazon.products || [],
  960. topBrands: (amazon.topBrands || []).map((b) => [b.brand, b.sales]),
  961. priceBuckets: amazon.priceBuckets || {},
  962. // 抖音
  963. douyin,
  964. // TikTok(Amazon 的内容端对标)
  965. tiktok,
  966. // 市场情报(Douyin 站级热榜 + TikTok Ads 官方数据)
  967. marketIntel: analyzeMarketIntel(voc.market_intel),
  968. };
  969. }
  970. // ============================================================
  971. // Market Intel 分析:TikTok Ads 情报 + Douyin 全站热榜摘要
  972. // ============================================================
  973. function analyzeMarketIntel(mi) {
  974. if (!mi) return { empty: true };
  975. // TT Ads:每产品提取 best-performing keyword(以 impression 为序)
  976. const adsEntries = Object.entries(mi.tt_ads_by_keyword || {});
  977. const insightsList = adsEntries
  978. .map(([kw, d]) => d.insights ? { keyword: kw, ...d.insights } : null)
  979. .filter(Boolean)
  980. .sort((a, b) => (b.impression || 0) - (a.impression || 0));
  981. // Related keywords:按 score 排序的 Top 30(来自有数据的第一个关键词)
  982. let relatedKeywords = [];
  983. let relatedFromKw = null;
  984. for (const [kw, d] of adsEntries) {
  985. if (Array.isArray(d.related_keywords) && d.related_keywords.length) {
  986. relatedKeywords = d.related_keywords.slice(0, 30);
  987. relatedFromKw = kw;
  988. break;
  989. }
  990. }
  991. // Douyin 全站热榜采样(与产品无关的"内容生态基线")
  992. const global = mi.trends_global_sample || {};
  993. const hotWordTop = (global.hot_word_top5 || []).slice(0, 6);
  994. const webHotTop = (global.web_hot_top5 || []).slice(0, 6);
  995. const hotTopicTop = (global.hot_topic_top5 || []).slice(0, 5);
  996. // Douyin 品类过滤命中(若有)
  997. const filtered = mi.douyin_filtered || {};
  998. const categoryHitTotal = Object.values(filtered).reduce((s, a) => s + (a?.length || 0), 0);
  999. return {
  1000. collected_at: mi.collected_at,
  1001. ttAds: {
  1002. insights: insightsList, // [{keyword, impression, ctr, cvr, post, post_change, cost, like, comment, share, video_list}]
  1003. relatedKeywords, // [{name, score}] Top 30
  1004. relatedFromKeyword: relatedFromKw,
  1005. hasData: insightsList.length > 0 || relatedKeywords.length > 0,
  1006. },
  1007. douyinGlobal: {
  1008. hotWords: hotWordTop, // [{title, score}]
  1009. webHotSearch: webHotTop, // [{word, view_count}]
  1010. hotTopics: hotTopicTop, // [{challenge_name, play_cnt, publish_cnt}]
  1011. filterKeywords: mi.douyin_filter_keywords || [],
  1012. categoryFiltered: filtered, // 本地过滤命中(可能全为 0)
  1013. categoryHitTotal,
  1014. hasGlobal: hotWordTop.length > 0 || webHotTop.length > 0,
  1015. },
  1016. };
  1017. }
  1018. function extractThemes(texts) {
  1019. const negKeywords = [
  1020. { kw: ['难吃', '太苦', '味道', '歹毒', '呕', '吐'], tag: '口感差' },
  1021. { kw: ['副作用', '伤', '不适', '难受', '拉肚'], tag: '副作用担忧' },
  1022. { kw: ['假货', '骗', '智商税', '坑', '踩雷'], tag: '信任危机' },
  1023. { kw: ['贵', '太贵', '不划算', '太坑'], tag: '价格敏感' },
  1024. { kw: ['没用', '没效果', '无效', '白吃'], tag: '效果不显' },
  1025. ];
  1026. const posKeywords = [
  1027. { kw: ['好用', '有效', '管用', '效果不错', '真的好'], tag: '效果认可' },
  1028. { kw: ['好吃', '味道不错', '好喝', '喜欢', '期待'], tag: '口感好' },
  1029. { kw: ['回购', '长期', '一直吃', '推荐', '必备'], tag: '高复购意向' },
  1030. { kw: ['舒服', '放心', '安心', '温和'], tag: '体验温和' },
  1031. { kw: ['方便', '便携', '携带'], tag: '便利场景' },
  1032. ];
  1033. const scenarioKeywords = [
  1034. { kw: ['熬夜', '应酬', '喝酒', '酒局', '宿醉'], tag: '应酬场景' },
  1035. { kw: ['上班', '职场', '通勤', '办公室', '加班'], tag: '职场场景' },
  1036. { kw: ['宝宝', '孩子', '儿童', '小朋友', '宝妈'], tag: '育儿场景' },
  1037. { kw: ['减肥', '减脂', '控制', '轻断食'], tag: '减肥场景' },
  1038. { kw: ['养胃', '胃不好', '胃痛', '消化', '肠胃'], tag: '胃肠养护' },
  1039. ];
  1040. const count = (patterns, texts) => {
  1041. const tagCount = {}, tagExamples = {};
  1042. for (const text of texts) {
  1043. for (const { kw, tag } of patterns) {
  1044. if (kw.some((k) => text.includes(k))) {
  1045. tagCount[tag] = (tagCount[tag] || 0) + 1;
  1046. if (!tagExamples[tag]) tagExamples[tag] = [];
  1047. if (tagExamples[tag].length < 3) tagExamples[tag].push(text);
  1048. }
  1049. }
  1050. }
  1051. return Object.entries(tagCount).map(([tag, count]) => ({ tag, count, examples: tagExamples[tag] || [] }))
  1052. .sort((a, b) => b.count - a.count);
  1053. };
  1054. return {
  1055. pain: count(negKeywords, texts),
  1056. bright: count(posKeywords, texts),
  1057. scenarios: count(scenarioKeywords, texts),
  1058. };
  1059. }
  1060. module.exports = { analyze, extractThemes, esc, fmtNum, fmtMoney, cleanText };