collect.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. #!/usr/bin/env node
  2. /**
  3. * 江中肝纯片 · VOC 验证数据扩采脚本
  4. *
  5. * 目标:围绕《1.VOC方向定位.md》5 模块 × 4 渠道 × 6 竞品,
  6. * 采集高质量小红书评论(评论颗粒度),补充现有 jiangzhong-liver-voc.json。
  7. *
  8. * 输出:data/jiangzhong-liver-voc-extended.json
  9. * - by_module.{package|pricing|positioning|scene|competitor}.{keyword}.{notes|comments|users}
  10. *
  11. * 策略:
  12. * - 分批次(argv: 第 N 批,每批 3 关键词)避免限流
  13. * - 每关键词取搜索结果按 comments_count 降序前 5 篇笔记,抓前 3 篇的评论
  14. * - 支持续跑:已有关键词跳过(--force 强制重抓)
  15. */
  16. const fs = require('fs');
  17. const path = require('path');
  18. const os = require('os');
  19. const https = require('https');
  20. // ============================================================
  21. // 配置
  22. // ============================================================
  23. const XHS_SKILL_CONFIG = path.join(os.homedir(), '.openclaw', 'skills', 'xiaohongshu-search-notes', 'api-config.json');
  24. const xhsConf = JSON.parse(fs.readFileSync(XHS_SKILL_CONFIG, 'utf-8'));
  25. const TIKHUB_TOKEN = xhsConf.endpoint.headers.Authorization.replace(/^Bearer\s+/, '');
  26. const DATA_DIR = path.resolve(__dirname, '..', '..', 'data');
  27. const OUT_PATH = path.join(DATA_DIR, 'jiangzhong-liver-voc-extended.json');
  28. const LOG_PATH = path.join(DATA_DIR, 'voc-validation-collect.log');
  29. // 5 模块 × 关键词(按优先级分组,每批 3-4 关键词)
  30. const KEYWORD_GROUPS = {
  31. // ===== 定位与话术模块(最高优先级 · 差异化验证)=====
  32. positioning: [
  33. { kw: '藤茶 护肝', note: '核心差异化·藤茶概念认知' },
  34. { kw: '二氢杨梅素', note: '核心成分·DMY 认知度' },
  35. { kw: '解酒药', note: '核心场景·餐饮社交' },
  36. ],
  37. // ===== 场景适配模块 =====
  38. scene: [
  39. { kw: '熬夜 护肝', note: '日常养护场景·女性+打工人' },
  40. { kw: '送礼 保健品', note: '礼品渠道·体面感' },
  41. { kw: '应酬 护肝', note: '餐饮渠道·男性酒局' },
  42. ],
  43. // ===== 竞品认知模块 =====
  44. competitor: [
  45. { kw: '护肝片 副作用', note: '药品竞品·护肝片吐槽' },
  46. { kw: '易善复', note: '西药竞品·胃肠反应' },
  47. { kw: 'Swisse 水飞蓟', note: '保健品竞品·海外对标' },
  48. { kw: '海王金樽', note: '失败案例·解酒品复盘' },
  49. { kw: '汤臣倍健 护肝', note: '国产保健品竞品' },
  50. ],
  51. // ===== 竞品扩展(Batch 2 补采:片仔癀 / 熊胆粉 / 葵花 / 解酒神器)=====
  52. competitor_ext: [
  53. { kw: '片仔癀 护肝', note: '名贵中成药·天价对标' },
  54. { kw: '熊胆粉', note: '名贵药材·寒凉/伦理敏感' },
  55. { kw: '葵花护肝片', note: '国产老牌药·品牌老化' },
  56. { kw: '解酒神器', note: '餐饮大池·酒局场景 VOC' },
  57. ],
  58. // ===== 包装 & 定价模块 =====
  59. package_pricing: [
  60. { kw: '护肝片 包装', note: '包装吐槽·"土"的印证' },
  61. { kw: '保健品 送礼装', note: '礼品包装偏好' },
  62. ],
  63. };
  64. // ============================================================
  65. // HTTP helper
  66. // ============================================================
  67. const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  68. function httpGet(host, urlPath, auth, attempt = 1, maxAttempts = 3) {
  69. return new Promise((resolve, reject) => {
  70. const headers = { Accept: 'application/json' };
  71. if (auth) headers.Authorization = `Bearer ${auth}`;
  72. const req = https.request({
  73. hostname: host, path: urlPath, method: 'GET', headers, timeout: 45000,
  74. }, (res) => {
  75. const chunks = [];
  76. res.on('data', (c) => chunks.push(c));
  77. res.on('end', async () => {
  78. const body = Buffer.concat(chunks).toString('utf-8');
  79. if (res.statusCode === 200) {
  80. try { resolve(JSON.parse(body)); }
  81. catch (e) { resolve({ _error: { parse: e.message, body: body.slice(0, 300) } }); }
  82. } else if ([400, 429, 500, 502, 503, 504].includes(res.statusCode) && attempt < maxAttempts) {
  83. const wait = 1500 * Math.pow(1.8, attempt - 1);
  84. console.log(` ↻ retry#${attempt} in ${wait}ms (status=${res.statusCode})`);
  85. await sleep(wait);
  86. resolve(httpGet(host, urlPath, auth, attempt + 1, maxAttempts));
  87. } else {
  88. resolve({ _error: { status: res.statusCode, body: body.slice(0, 300) } });
  89. }
  90. });
  91. });
  92. req.on('error', async (err) => {
  93. if (attempt < maxAttempts) {
  94. await sleep(2000 * attempt);
  95. resolve(httpGet(host, urlPath, auth, attempt + 1, maxAttempts));
  96. } else reject(err);
  97. });
  98. req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
  99. req.end();
  100. });
  101. }
  102. // ============================================================
  103. // 小红书采集(单关键词)
  104. // ============================================================
  105. async function collectXhsForKeyword(kw, opts = {}) {
  106. const { topNotes = 5, commentsPerNote = 50 } = opts;
  107. console.log(`\n [xhs] 🔍 "${kw}"`);
  108. const searchRes = await httpGet('server.fmode.cn',
  109. `/thapi/v1/xiaohongshu/app/search_notes?keyword=${encodeURIComponent(kw)}&page=1`,
  110. TIKHUB_TOKEN);
  111. const items = searchRes?.data?.data?.items || [];
  112. const notes = items.map((w) => w.note).filter((n) => n?.id);
  113. console.log(` ✓ ${notes.length} 条笔记`);
  114. // 按评论数降序取前 N 篇
  115. const topNotesArr = notes
  116. .filter((n) => (n.comments_count || 0) > 3)
  117. .sort((a, b) => (b.comments_count || 0) - (a.comments_count || 0))
  118. .slice(0, topNotes);
  119. // 抓评论(可翻页到目标数量)
  120. const comments = {};
  121. for (const note of topNotesArr) {
  122. await sleep(600);
  123. const preview = String(note.title || note.desc || note.id).slice(0, 30);
  124. console.log(` 💬 "${preview}" (${note.comments_count} 评论)`);
  125. const noteComments = [];
  126. let cursor = '';
  127. let pages = 0;
  128. const maxPages = Math.ceil(commentsPerNote / 10);
  129. while (pages < maxPages) {
  130. try {
  131. const cRes = await httpGet('server.fmode.cn',
  132. `/thapi/v1/xiaohongshu/app/get_note_comments?note_id=${note.id}&cursor=${encodeURIComponent(cursor)}`,
  133. TIKHUB_TOKEN);
  134. const batch = cRes?.data?.data?.comments || [];
  135. if (!batch.length) break;
  136. noteComments.push(...batch);
  137. cursor = cRes?.data?.data?.cursor || '';
  138. if (!cursor || !cRes?.data?.data?.has_more) break;
  139. pages++;
  140. await sleep(400);
  141. } catch (e) {
  142. console.log(` ↻ ${note.id} err=${e.message}`);
  143. break;
  144. }
  145. }
  146. comments[note.id] = noteComments;
  147. console.log(` → ${noteComments.length} 条评论`);
  148. }
  149. // 收集作者 ID(不深挖用户详情,节省 API)
  150. const userIds = new Set();
  151. topNotesArr.forEach((n) => n?.user?.userid && userIds.add(n.user.userid));
  152. return { notes, top_notes: topNotesArr.map((n) => n.id), comments, user_ids: Array.from(userIds) };
  153. }
  154. // ============================================================
  155. // Main
  156. // ============================================================
  157. async function main() {
  158. const args = process.argv.slice(2);
  159. const force = args.includes('--force');
  160. const groupArg = args.find((a) => !a.startsWith('--'));
  161. // 读已有数据(续跑)
  162. let extended = {};
  163. if (fs.existsSync(OUT_PATH)) {
  164. try { extended = JSON.parse(fs.readFileSync(OUT_PATH, 'utf-8')); }
  165. catch { extended = {}; }
  166. }
  167. if (!extended.by_module) extended.by_module = {};
  168. if (!extended.meta) extended.meta = { created_at: new Date().toISOString() };
  169. const logLines = [];
  170. function log(msg) {
  171. console.log(msg);
  172. logLines.push(`[${new Date().toISOString()}] ${msg}`);
  173. }
  174. log('╔══════════════════════════════════════════════════════════╗');
  175. log('║ 江中肝纯片 · VOC 验证扩采(多平台评论颗粒度) ║');
  176. log('╚══════════════════════════════════════════════════════════╝');
  177. log(` 输出: ${OUT_PATH}`);
  178. log(` TikHub token: ${TIKHUB_TOKEN.slice(0, 8)}...`);
  179. const groups = groupArg ? [groupArg] : Object.keys(KEYWORD_GROUPS);
  180. log(` 目标模块: ${groups.join(', ')}`);
  181. log(` 强制重抓: ${force}`);
  182. log('');
  183. const t0 = Date.now();
  184. let totalNotes = 0, totalComments = 0;
  185. for (const groupName of groups) {
  186. if (!KEYWORD_GROUPS[groupName]) {
  187. log(` ⚠ 未知模块: ${groupName}`);
  188. continue;
  189. }
  190. log(`\n━━━━━ 模块: ${groupName} ━━━━━`);
  191. if (!extended.by_module[groupName]) extended.by_module[groupName] = {};
  192. for (const { kw, note } of KEYWORD_GROUPS[groupName]) {
  193. if (!force && extended.by_module[groupName][kw]?.notes?.length > 0) {
  194. log(` ⏭ 跳过(已采集): "${kw}" —— ${extended.by_module[groupName][kw].notes.length} 笔记 / ${Object.values(extended.by_module[groupName][kw].comments || {}).flat().length} 评论`);
  195. continue;
  196. }
  197. try {
  198. const result = await collectXhsForKeyword(kw, { topNotes: 5, commentsPerNote: 50 });
  199. result._meta = { keyword: kw, purpose: note, collected_at: new Date().toISOString() };
  200. extended.by_module[groupName][kw] = result;
  201. const nCnt = result.notes.length;
  202. const cCnt = Object.values(result.comments).flat().length;
  203. totalNotes += nCnt;
  204. totalComments += cCnt;
  205. log(` ✅ "${kw}" —— ${nCnt} 笔记 / ${cCnt} 评论`);
  206. // 每个关键词采完就保存,避免中断丢失
  207. fs.writeFileSync(OUT_PATH, JSON.stringify(extended, null, 2), 'utf-8');
  208. await sleep(1200);
  209. } catch (e) {
  210. log(` ❌ "${kw}" —— ${e.message}`);
  211. extended.by_module[groupName][kw] = { _error: e.message, _meta: { keyword: kw } };
  212. }
  213. }
  214. }
  215. extended.meta.last_updated = new Date().toISOString();
  216. extended.meta.total_notes = totalNotes;
  217. extended.meta.total_comments = totalComments;
  218. fs.writeFileSync(OUT_PATH, JSON.stringify(extended, null, 2), 'utf-8');
  219. fs.writeFileSync(LOG_PATH, logLines.join('\n'), 'utf-8');
  220. const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
  221. log('');
  222. log('╔══════════════════════════════════════════════════════════╗');
  223. log(`║ ✅ 扩采完成 (${elapsed}s) ${totalNotes} 笔记 / ${totalComments} 评论`);
  224. log('╚══════════════════════════════════════════════════════════╝');
  225. log(` 输出: ${OUT_PATH} (${(fs.statSync(OUT_PATH).size / 1024).toFixed(1)} KB)`);
  226. log(` 日志: ${LOG_PATH}`);
  227. // 保存最终 log
  228. fs.writeFileSync(LOG_PATH, logLines.join('\n'), 'utf-8');
  229. }
  230. main().catch((err) => {
  231. console.error('❌ 致命错误:', err);
  232. process.exit(1);
  233. });