collect-market-intel.js 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. #!/usr/bin/env node
  2. /**
  3. * Market Intel 采集模块(TikHub)
  4. *
  5. * 两条数据线:
  6. * A. 公共 Douyin 站级热榜快照(3 产品共享、每次采集 1 份)
  7. * - fetch_hot_total_hot_word_list → 全站热词榜
  8. * - web_fetch_hot_search_result → 抖音 Web 首页热搜榜
  9. * - fetch_hot_total_high_topic_list → 全站热挑战榜
  10. * - fetch_hot_total_high_like_list → 全站热赞视频榜
  11. * 存到 data/douyin-trends-snapshot.json
  12. *
  13. * B. TikTok Ads 官方情报(按产品英文关键词)
  14. * - get_related_keywords → Creative Center 相关词网络(Top 50 w/ score)
  15. * - get_keyword_insights → 单关键词市场情报(impression/cost/ctr/cvr/post_change)
  16. * 合并到产品 VOC JSON 的 market_intel 字段
  17. */
  18. const fs = require('fs');
  19. const path = require('path');
  20. const os = require('os');
  21. const https = require('https');
  22. const TIKHUB_TOKEN = JSON.parse(
  23. fs.readFileSync(
  24. path.join(os.homedir(), '.openclaw', 'skills', 'xiaohongshu-search-notes', 'api-config.json'),
  25. 'utf-8'
  26. )
  27. ).endpoint.headers.Authorization.replace(/^Bearer\s+/, '');
  28. const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  29. function request(method, apiPath, { params = {}, body = null } = {}) {
  30. const qs = Object.entries(params)
  31. .filter(([, v]) => v !== undefined && v !== null && v !== '')
  32. .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
  33. .join('&');
  34. const fullPath = qs ? `${apiPath}?${qs}` : apiPath;
  35. const bodyStr = body ? JSON.stringify(body) : null;
  36. return new Promise((resolve) => {
  37. const headers = { Authorization: `Bearer ${TIKHUB_TOKEN}`, Accept: 'application/json' };
  38. if (bodyStr) {
  39. headers['Content-Type'] = 'application/json';
  40. headers['Content-Length'] = Buffer.byteLength(bodyStr);
  41. }
  42. const opts = { hostname: 'api.tikhub.io', path: fullPath, method, headers };
  43. const req = https.request(opts, (res) => {
  44. const chunks = [];
  45. res.on('data', (c) => chunks.push(c));
  46. res.on('end', () => {
  47. const raw = Buffer.concat(chunks).toString('utf-8');
  48. try { resolve({ status: res.statusCode, json: JSON.parse(raw) }); }
  49. catch (e) { resolve({ status: res.statusCode, json: { _raw: raw.slice(0, 300) } }); }
  50. });
  51. });
  52. req.on('error', (e) => resolve({ status: 0, json: { _error: e.message } }));
  53. req.setTimeout(30000, () => { req.destroy(); resolve({ status: 0, json: { _error: 'timeout' } }); });
  54. if (bodyStr) req.write(bodyStr);
  55. req.end();
  56. });
  57. }
  58. // TikHub 嵌套规律:resp.data.data.xxx
  59. function pickData(j) { return j?.data?.data; }
  60. // ============================================================
  61. // A. Douyin 公共站级热榜(3 产品共享)
  62. // ============================================================
  63. async function collectDouyinTrendsOnce() {
  64. console.log('\n=== [A] Douyin 站级热榜快照(3 产品共享)===');
  65. const out = { collected_at: new Date().toISOString() };
  66. // 1) 热词榜(title/score/trends)
  67. console.log(' 📊 fetch_hot_total_hot_word_list ...');
  68. const rHotWord = await request('POST', '/api/v1/douyin/billboard/fetch_hot_total_hot_word_list',
  69. { body: { date_window: 24, page: 1, page_size: 30 } });
  70. out.hot_word_list = pickData(rHotWord.json)?.word_list || [];
  71. console.log(` → ${out.hot_word_list.length} 热词`);
  72. await sleep(600);
  73. // 2) Web 首页热搜榜(word/view_count/discuss_video_count)
  74. console.log(' 📊 web/fetch_hot_search_result ...');
  75. const rWebHot = await request('GET', '/api/v1/douyin/web/fetch_hot_search_result');
  76. out.web_hot_search = pickData(rWebHot.json)?.word_list || [];
  77. console.log(` → ${out.web_hot_search.length} 热搜榜`);
  78. await sleep(600);
  79. // 3) 热挑战榜(challenge_name/play_cnt/publish_cnt)
  80. console.log(' 📊 fetch_hot_total_high_topic_list ...');
  81. const rHotTopic = await request('POST', '/api/v1/douyin/billboard/fetch_hot_total_high_topic_list',
  82. { body: { date_window: 24, page: 1, page_size: 30 } });
  83. out.hot_topic_list = pickData(rHotTopic.json)?.objs || [];
  84. console.log(` → ${out.hot_topic_list.length} 热挑战`);
  85. await sleep(600);
  86. // 4) 热赞视频榜(item_title/nick_name/play_cnt/like_cnt)
  87. console.log(' 📊 fetch_hot_total_high_like_list ...');
  88. const rHotLike = await request('POST', '/api/v1/douyin/billboard/fetch_hot_total_high_like_list',
  89. { body: { date_window: 24, page: 1, page_size: 30 } });
  90. out.hot_like_videos = pickData(rHotLike.json)?.objs || [];
  91. console.log(` → ${out.hot_like_videos.length} 热赞视频`);
  92. await sleep(600);
  93. // 5) 热播视频榜(补强)
  94. console.log(' 📊 fetch_hot_total_high_play_list ...');
  95. const rHotPlay = await request('POST', '/api/v1/douyin/billboard/fetch_hot_total_high_play_list',
  96. { body: { date_window: 24, page: 1, page_size: 30 } });
  97. out.hot_play_videos = pickData(rHotPlay.json)?.objs || [];
  98. console.log(` → ${out.hot_play_videos.length} 热播视频`);
  99. return out;
  100. }
  101. // ============================================================
  102. // 本地品类关键词过滤器(把全站热榜筛到本品类相关)
  103. // ============================================================
  104. function filterByKeywords(items, keywords, getText) {
  105. if (!items || !items.length || !keywords || !keywords.length) return [];
  106. const kws = keywords.map((k) => String(k).toLowerCase()).filter(Boolean);
  107. return items.filter((item) => {
  108. const text = String(getText(item) || '').toLowerCase();
  109. return kws.some((kw) => text.includes(kw));
  110. });
  111. }
  112. function buildDouyinCategoryFiltered(trends, productKeywords) {
  113. const kws = productKeywords || [];
  114. return {
  115. hot_words: filterByKeywords(trends.hot_word_list || [], kws, (w) => w.title),
  116. web_hot_search: filterByKeywords(trends.web_hot_search || [], kws, (w) => w.word),
  117. hot_topics: filterByKeywords(trends.hot_topic_list || [], kws, (t) => t.challenge_name),
  118. hot_like_videos: filterByKeywords(trends.hot_like_videos || [], kws, (v) => v.item_title),
  119. hot_play_videos: filterByKeywords(trends.hot_play_videos || [], kws, (v) => v.item_title),
  120. };
  121. }
  122. // ============================================================
  123. // B. TikTok Ads 情报(按英文关键词)
  124. // ============================================================
  125. async function collectTikTokAdsForKeyword(keywordEn, opts = {}) {
  126. const { period = 30, countryCode = 'US' } = opts;
  127. const out = { keyword: keywordEn, period, country_code: countryCode };
  128. console.log(` 🔗 related_keywords "${keywordEn}"`);
  129. const rRelated = await request('GET', '/api/v1/tiktok/ads/get_related_keywords',
  130. { params: { keyword: keywordEn, period, country_code: countryCode } });
  131. const relatedInner = rRelated.json?.data?.data;
  132. // related: data.list [{ name, score }] 50 条
  133. out.related_keywords = Array.isArray(relatedInner?.list) ? relatedInner.list.slice(0, 50) : [];
  134. console.log(` → ${out.related_keywords.length} 相关词`);
  135. await sleep(600);
  136. console.log(` 💡 keyword_insights "${keywordEn}"`);
  137. const rInsights = await request('GET', '/api/v1/tiktok/ads/get_keyword_insights',
  138. { params: { keyword: keywordEn, period, country_code: countryCode } });
  139. const insightsInner = rInsights.json?.data?.data;
  140. // insights: data.keyword_list[0] { impression, cost, ctr, cvr, post, post_change, comment, share, like, video_list }
  141. out.insights = Array.isArray(insightsInner?.keyword_list) && insightsInner.keyword_list.length
  142. ? insightsInner.keyword_list[0]
  143. : null;
  144. if (out.insights) {
  145. console.log(` → impression ${out.insights.impression?.toLocaleString?.() || out.insights.impression} · CTR ${out.insights.ctr}% · post +${out.insights.post_change}%`);
  146. } else {
  147. console.log(` → 无 insights 数据`);
  148. }
  149. await sleep(600);
  150. return out;
  151. }
  152. // ============================================================
  153. // 对外入口:每产品跑 market intel
  154. // ============================================================
  155. async function collectMarketIntelForProduct(productName, cfg) {
  156. // cfg: { keywords_en: ['milk thistle', ...], douyin_filter_keywords: ['肝', '解酒', ...] }
  157. console.log(`\n── [market] ${productName} ──`);
  158. const enKws = (cfg.keywords_en || []).slice(0, 2); // 最多 2 个英文关键词,每个 2 次 API
  159. const adsByKeyword = {};
  160. for (const kw of enKws) {
  161. adsByKeyword[kw] = await collectTikTokAdsForKeyword(kw, { period: 30, countryCode: 'US' });
  162. }
  163. return {
  164. ads_by_keyword: adsByKeyword,
  165. douyin_filter_keywords: cfg.douyin_filter_keywords || [],
  166. };
  167. }
  168. module.exports = {
  169. collectDouyinTrendsOnce,
  170. buildDouyinCategoryFiltered,
  171. collectTikTokAdsForKeyword,
  172. collectMarketIntelForProduct,
  173. };