jiangzhong-voc-collect.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. #!/usr/bin/env node
  2. /**
  3. * 江中三产品 VOC 数据采集脚本 · v2(增强版)
  4. *
  5. * 目标:为「江中肝纯片 / 儿童乳酸菌素片 / 江中猴菇饮」并行采集:
  6. * - 小红书:3 关键词搜索 + Top 笔记评论 + 作者画像
  7. * - 抖音:官方分享链 → aweme + 评论 + 作者主页(如存在 cookie 再加关键词搜索)
  8. * - Amazon:4 英文关键词 × 3 页 Sorftime + 产品详情 + 评论
  9. *
  10. * 输出:./data/jiangzhong-{产品}-voc.json
  11. */
  12. const fs = require('fs');
  13. const path = require('path');
  14. const os = require('os');
  15. const https = require('https');
  16. const { collectAmazonForProduct } = require('./collect-amazon');
  17. const { collectDouyinForProduct, loadCookie, COOKIE_PATH } = require('./collect-douyin');
  18. // ============================================================
  19. // 配置
  20. // ============================================================
  21. const XHS_SKILL_CONFIG = path.join(os.homedir(), '.openclaw', 'skills', 'xiaohongshu-search-notes', 'api-config.json');
  22. const xhsConf = JSON.parse(fs.readFileSync(XHS_SKILL_CONFIG, 'utf-8'));
  23. const TIKHUB_TOKEN = xhsConf.endpoint.headers.Authorization.replace(/^Bearer\s+/, '');
  24. const DATA_DIR = path.resolve(__dirname, '..', '..', 'data');
  25. if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
  26. // ============================================================
  27. // 三个产品配置
  28. // ============================================================
  29. // ⚠ douyin_share_urls 按「官方号 + 品牌竞品号 + 垂类博主号」三卡顺序填写
  30. // 收到用户 6 条分享链后,在对应位置取消注释并填入
  31. const PRODUCTS = [
  32. {
  33. id: 'liver',
  34. name: '江中肝纯片',
  35. category: 'OTC 保肝片剂',
  36. keywords_cn: ['肝纯片', '江中肝纯片', '护肝片'],
  37. keywords_en: ['milk thistle', 'liver support supplement', 'liver cleanse detox', 'silymarin'],
  38. douyin_share_url: 'https://v.douyin.com/BMklXPTyiUM/', // 向下兼容
  39. douyin_share_urls: [
  40. { share_url: 'https://v.douyin.com/BMklXPTyiUM/', role: '官方号', label: '江中(现绑)' },
  41. // { share_url: '___待填___', role: '品牌竞品', label: '葵花护肝片 / 葵花药业' },
  42. // { share_url: '___待填___', role: '垂类博主', label: '肝病科医生 / 护肝博主' },
  43. ],
  44. sorftime_pages: 3, sorftime_top_detail: 6, sorftime_top_reviews: 3,
  45. outfile: 'jiangzhong-liver-voc.json',
  46. },
  47. {
  48. id: 'probiotic',
  49. name: '儿童乳酸菌素片',
  50. category: '儿童功能食品 / 肠道调理',
  51. keywords_cn: ['乳酸菌素片', '儿童乳酸菌素片', '儿童肠胃调理'],
  52. keywords_en: ['kids probiotics', 'children probiotics gummy', 'probiotics gummies for kids', 'childrens digestive supplement'],
  53. douyin_share_url: 'https://v.douyin.com/1eV76OP0FNM/',
  54. douyin_share_urls: [
  55. { share_url: 'https://v.douyin.com/1eV76OP0FNM/', role: '官方号', label: '江中食疗(现绑)' },
  56. // { share_url: '___待填___', role: '品牌竞品', label: '妈咪爱 / 亿活 / 合生元' },
  57. // { share_url: '___待填___', role: '垂类博主', label: '儿科医生 / 育儿达人' },
  58. ],
  59. sorftime_pages: 3, sorftime_top_detail: 6, sorftime_top_reviews: 3,
  60. outfile: 'jiangzhong-probiotic-voc.json',
  61. },
  62. {
  63. id: 'monkey',
  64. name: '江中猴菇饮',
  65. category: '胃肠道养胃饮品',
  66. keywords_cn: ['猴菇饮', '江中猴菇', '养胃饮'],
  67. keywords_en: ['lions mane mushroom supplement', 'lion mane mushroom', 'stomach gut health supplement', 'lions mane gummies'],
  68. douyin_share_url: 'https://v.douyin.com/C3mMbhRYKHw/',
  69. douyin_share_urls: [
  70. { share_url: 'https://v.douyin.com/C3mMbhRYKHw/', role: '官方号', label: '江中制药(现绑)' },
  71. // { share_url: '___待填___', role: '品牌竞品', label: '康恩贝 / 太阳神 / 三九' },
  72. // { share_url: '___待填___', role: '垂类博主', label: '胃肠科医生 / 养胃博主' },
  73. ],
  74. sorftime_pages: 3, sorftime_top_detail: 6, sorftime_top_reviews: 3,
  75. outfile: 'jiangzhong-monkey-voc.json',
  76. },
  77. ];
  78. // ============================================================
  79. // HTTP 请求助手(带重试)
  80. // ============================================================
  81. function httpRequest(options, bodyData = null, maxAttempts = 3) {
  82. return new Promise(async (resolve, reject) => {
  83. for (let attempt = 1; attempt <= maxAttempts; attempt++) {
  84. try {
  85. const result = await new Promise((res, rej) => {
  86. const req = https.request(options, (response) => {
  87. let data = [];
  88. response.on('data', (chunk) => data.push(chunk));
  89. response.on('end', () => {
  90. const body = Buffer.concat(data).toString('utf-8');
  91. res({ status: response.statusCode, body });
  92. });
  93. });
  94. req.on('error', rej);
  95. req.on('timeout', () => {
  96. req.destroy();
  97. rej(new Error('timeout'));
  98. });
  99. if (bodyData) req.write(bodyData);
  100. req.end();
  101. });
  102. if (result.status === 200) return resolve(result);
  103. if ([400, 429, 500, 502, 503, 504].includes(result.status) && attempt < maxAttempts) {
  104. const wait = 1500 * Math.pow(1.8, attempt - 1);
  105. console.log(` ↻ ${options.path.slice(0, 60)} → ${result.status} retry#${attempt} in ${wait}ms`);
  106. await new Promise((r) => setTimeout(r, wait));
  107. continue;
  108. }
  109. return resolve(result);
  110. } catch (e) {
  111. if (attempt === maxAttempts) return reject(e);
  112. await new Promise((r) => setTimeout(r, 2000 * attempt));
  113. }
  114. }
  115. });
  116. }
  117. async function getJson(host, path, auth) {
  118. const headers = { Accept: 'application/json' };
  119. if (auth) headers.Authorization = `Bearer ${auth}`;
  120. const { status, body } = await httpRequest({
  121. hostname: host,
  122. path,
  123. method: 'GET',
  124. headers,
  125. timeout: 60000,
  126. });
  127. if (status !== 200) return { _error: { status, body: body.slice(0, 300) } };
  128. try {
  129. return JSON.parse(body);
  130. } catch (e) {
  131. return { _error: { parse: e.message, body: body.slice(0, 300) } };
  132. }
  133. }
  134. async function postJson(host, path, bodyObj, auth, extraHeaders = {}) {
  135. const bodyStr = JSON.stringify(bodyObj);
  136. const headers = {
  137. Accept: 'application/json',
  138. 'Content-Type': 'application/json',
  139. 'Content-Length': Buffer.byteLength(bodyStr),
  140. ...extraHeaders,
  141. };
  142. if (auth) headers.Authorization = `Bearer ${auth}`;
  143. const { status, body } = await httpRequest({
  144. hostname: host,
  145. path,
  146. method: 'POST',
  147. headers,
  148. timeout: 60000,
  149. }, bodyStr);
  150. if (status !== 200) return { _error: { status, body: body.slice(0, 300) } };
  151. try {
  152. return JSON.parse(body);
  153. } catch (e) {
  154. return { _error: { parse: e.message, body: body.slice(0, 300) } };
  155. }
  156. }
  157. const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  158. // ============================================================
  159. // 小红书采集器
  160. // ============================================================
  161. async function collectXhsForKeyword(kw) {
  162. console.log(` [xhs] 🔍 搜索 "${kw}"`);
  163. const searchRes = await getJson('server.fmode.cn',
  164. `/thapi/v1/xiaohongshu/app/search_notes?keyword=${encodeURIComponent(kw)}&page=1`,
  165. TIKHUB_TOKEN);
  166. const items = searchRes?.data?.data?.items || [];
  167. const notes = items.map((w) => w.note).filter((n) => n?.id);
  168. console.log(` [xhs] ✓ ${notes.length} 条笔记`);
  169. // 取前 5 条最高评论数的笔记去抓评论
  170. const topNotes = notes
  171. .filter((n) => n.comments_count > 5)
  172. .sort((a, b) => (b.comments_count || 0) - (a.comments_count || 0))
  173. .slice(0, 5);
  174. const comments = {};
  175. const userIds = new Set();
  176. for (const note of topNotes) {
  177. if (!note?.id) continue;
  178. await sleep(800);
  179. const preview = String(note.title || note.desc || note.id).slice(0, 30);
  180. console.log(` [xhs] 💬 评论 "${preview}" (${note.comments_count})`);
  181. try {
  182. const cRes = await getJson('server.fmode.cn',
  183. `/thapi/v1/xiaohongshu/app/get_note_comments?note_id=${note.id}&cursor=`,
  184. TIKHUB_TOKEN);
  185. comments[note.id] = cRes?.data?.data?.comments || [];
  186. } catch (e) {
  187. console.log(` ↻ ${note.id} ${e.message}`);
  188. comments[note.id] = [];
  189. }
  190. if (note.user?.userid) userIds.add(note.user.userid);
  191. }
  192. // 收集其余笔记的作者 ID
  193. notes.forEach((n) => n?.user?.userid && userIds.add(n.user.userid));
  194. // 抓 top 3 笔记作者信息
  195. const users = {};
  196. const topUserIds = Array.from(userIds).slice(0, 5);
  197. for (const uid of topUserIds) {
  198. await sleep(800);
  199. console.log(` [xhs] 👤 用户 ${uid}`);
  200. const uRes = await getJson('server.fmode.cn',
  201. `/thapi/v1/xiaohongshu/app/get_user_info?user_id=${uid}`,
  202. TIKHUB_TOKEN);
  203. users[uid] = uRes?.data?.data || { _error: uRes?._error };
  204. }
  205. return { notes, comments, users };
  206. }
  207. // ============================================================
  208. // 单产品采集编排(使用模块化 collect-amazon + collect-douyin)
  209. // ============================================================
  210. async function collectOneProduct(product, cookie) {
  211. console.log('');
  212. console.log(`🦐 ===== ${product.name} (${product.category}) =====`);
  213. const startTs = Date.now();
  214. const out = {
  215. product: product.name,
  216. product_id: product.id,
  217. category: product.category,
  218. keywords: { cn: product.keywords_cn, en: product.keywords_en },
  219. douyin_share_url: product.douyin_share_url,
  220. xiaohongshu: { by_keyword: {} },
  221. douyin: null,
  222. amazon: null,
  223. collected_at: new Date().toISOString(),
  224. };
  225. // —— 小红书:3 个 CN 关键词 ——
  226. for (const kw of product.keywords_cn.slice(0, 3)) {
  227. try {
  228. out.xiaohongshu.by_keyword[kw] = await collectXhsForKeyword(kw);
  229. } catch (e) {
  230. console.log(` [xhs:${kw}] ❌ ${e.message}`);
  231. out.xiaohongshu.by_keyword[kw] = { _error: e.message };
  232. }
  233. await sleep(1000);
  234. }
  235. // —— Amazon:调模块 ——
  236. try {
  237. out.amazon = await collectAmazonForProduct(product);
  238. } catch (e) {
  239. console.log(` [amz] ❌ ${e.message}`);
  240. out.amazon = { _error: e.message };
  241. }
  242. // —— 抖音:调模块(按 share URL + 可选 cookie) ——
  243. try {
  244. out.douyin = await collectDouyinForProduct(product, cookie);
  245. } catch (e) {
  246. console.log(` [dy] ❌ ${e.message}`);
  247. out.douyin = { _error: e.message };
  248. }
  249. // 保存
  250. const outPath = path.join(DATA_DIR, product.outfile);
  251. fs.writeFileSync(outPath, JSON.stringify(out, null, 2), 'utf-8');
  252. const elapsed = ((Date.now() - startTs) / 1000).toFixed(1);
  253. console.log(`🦐 ===== ${product.name} ✅ 完成 (${elapsed}s) → ${outPath} =====`);
  254. return out;
  255. }
  256. // ============================================================
  257. // Main
  258. // ============================================================
  259. async function main() {
  260. console.log('');
  261. console.log('╔══════════════════════════════════════════════════════════╗');
  262. console.log('║ 江中三产品 VOC 数据采集 v2 (XHS + Amazon + Douyin) ║');
  263. console.log('╚══════════════════════════════════════════════════════════╝');
  264. console.log(` 📅 ${new Date().toLocaleString()}`);
  265. console.log(` 📁 输出目录: ${DATA_DIR}`);
  266. console.log(` 🔑 TikHub token: ${TIKHUB_TOKEN.slice(0, 8)}...`);
  267. // 加载 Douyin cookie (可选)
  268. const cookie = loadCookie();
  269. if (cookie) {
  270. console.log(` 🍪 Douyin cookie: 已加载 (${cookie.length} chars)`);
  271. } else {
  272. console.log(` 🍪 Douyin cookie: 未提供 (预计分享链解析会失败,可后续在 ${COOKIE_PATH} 提供)`);
  273. }
  274. const totalStart = Date.now();
  275. // CLI: 允许传单个产品 id 来只跑某个产品
  276. const arg = process.argv[2];
  277. const targets = arg ? PRODUCTS.filter((p) => p.id === arg) : PRODUCTS;
  278. if (arg && !targets.length) {
  279. console.error(`❌ unknown product: ${arg}, available: ${PRODUCTS.map((p) => p.id).join(',')}`);
  280. process.exit(1);
  281. }
  282. console.log(` 🎯 目标产品: ${targets.map((p) => p.id).join(', ')}`);
  283. // 产品间并发启动(避免总时长过长),产品内仍是 sequential
  284. const results = await Promise.all(targets.map((p) => collectOneProduct(p, cookie)));
  285. const totalElapsed = ((Date.now() - totalStart) / 1000).toFixed(1);
  286. console.log('');
  287. console.log('╔══════════════════════════════════════════════════════════╗');
  288. console.log(`║ ✅ 全部完成 (${totalElapsed}s)`);
  289. console.log('╚══════════════════════════════════════════════════════════╝');
  290. for (const r of results) {
  291. const xhsKws = Object.keys(r.xiaohongshu.by_keyword);
  292. const xhsNotes = xhsKws.reduce((s, k) => s + (r.xiaohongshu.by_keyword[k]?.notes?.length || 0), 0);
  293. const xhsCmts = xhsKws.reduce((s, k) => s + Object.values(r.xiaohongshu.by_keyword[k]?.comments || {}).flat().length, 0);
  294. const amzKws = Object.keys(r.amazon?.by_keyword || {});
  295. const amzProducts = r.amazon?.merged_products?.length || 0;
  296. const amzDetails = Object.keys(r.amazon?.product_details || {}).length;
  297. const amzReviews = Object.values(r.amazon?.reviews_by_asin || {})
  298. .reduce((s, v) => s + ((v?.Reviews?.length || v?.reviews?.length) || 0), 0);
  299. const dyComments = r.douyin?.comments?.length || 0;
  300. const dyUserPosts = r.douyin?.user_posts?.length || 0;
  301. const dySearches = Object.values(r.douyin?.searches || {}).reduce((s, v) => s + (Array.isArray(v) ? v.length : 0), 0);
  302. console.log(` 📊 ${r.product}:`);
  303. console.log(` xhs : ${xhsNotes} 笔记 / ${xhsCmts} 评论 / ${xhsKws.length} 关键词`);
  304. console.log(` amazon : ${amzProducts} 产品 (${amzKws.length} kw × 3页) · ${amzDetails} 详情 · ${amzReviews} 评论`);
  305. console.log(` douyin : ${r.douyin?.video ? '✓视频' : '✗视频'} / ${dyComments} 评论 / ${dyUserPosts} 作者作品 / ${dySearches} 搜索视频`);
  306. }
  307. }
  308. main().catch((err) => {
  309. console.error('❌ 致命错误:', err);
  310. process.exit(1);
  311. });