| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260 |
- #!/usr/bin/env node
- /**
- * 江中肝纯片 · VOC 验证数据扩采脚本
- *
- * 目标:围绕《1.VOC方向定位.md》5 模块 × 4 渠道 × 6 竞品,
- * 采集高质量小红书评论(评论颗粒度),补充现有 jiangzhong-liver-voc.json。
- *
- * 输出:data/jiangzhong-liver-voc-extended.json
- * - by_module.{package|pricing|positioning|scene|competitor}.{keyword}.{notes|comments|users}
- *
- * 策略:
- * - 分批次(argv: 第 N 批,每批 3 关键词)避免限流
- * - 每关键词取搜索结果按 comments_count 降序前 5 篇笔记,抓前 3 篇的评论
- * - 支持续跑:已有关键词跳过(--force 强制重抓)
- */
- const fs = require('fs');
- const path = require('path');
- const os = require('os');
- const https = require('https');
- // ============================================================
- // 配置
- // ============================================================
- const XHS_SKILL_CONFIG = path.join(os.homedir(), '.openclaw', 'skills', 'xiaohongshu-search-notes', 'api-config.json');
- const xhsConf = JSON.parse(fs.readFileSync(XHS_SKILL_CONFIG, 'utf-8'));
- const TIKHUB_TOKEN = xhsConf.endpoint.headers.Authorization.replace(/^Bearer\s+/, '');
- // __dirname = scripts/tools/voc-validation → data 在 3 级以上
- const DATA_DIR = path.resolve(__dirname, '..', '..', '..', 'data');
- const OUT_PATH = path.join(DATA_DIR, 'jiangzhong-liver-voc-extended.json');
- const LOG_PATH = path.join(DATA_DIR, 'voc-validation-collect.log');
- // 5 模块 × 关键词(按优先级分组,每批 3-4 关键词)
- const KEYWORD_GROUPS = {
- // ===== 定位与话术模块(最高优先级 · 差异化验证)=====
- positioning: [
- { kw: '藤茶 护肝', note: '核心差异化·藤茶概念认知' },
- { kw: '二氢杨梅素', note: '核心成分·DMY 认知度' },
- { kw: '解酒药', note: '核心场景·餐饮社交' },
- ],
- // ===== 场景适配模块 =====
- scene: [
- { kw: '熬夜 护肝', note: '日常养护场景·女性+打工人' },
- { kw: '送礼 保健品', note: '礼品渠道·体面感' },
- { kw: '应酬 护肝', note: '餐饮渠道·男性酒局' },
- ],
- // ===== 竞品认知模块 =====
- competitor: [
- { kw: '护肝片 副作用', note: '药品竞品·护肝片吐槽' },
- { kw: '易善复', note: '西药竞品·胃肠反应' },
- { kw: 'Swisse 水飞蓟', note: '保健品竞品·海外对标' },
- { kw: '海王金樽', note: '失败案例·解酒品复盘' },
- { kw: '汤臣倍健 护肝', note: '国产保健品竞品' },
- ],
- // ===== 竞品扩展(Batch 2 补采:片仔癀 / 熊胆粉 / 葵花 / 解酒神器)=====
- competitor_ext: [
- { kw: '片仔癀 护肝', note: '名贵中成药·天价对标' },
- { kw: '熊胆粉', note: '名贵药材·寒凉/伦理敏感' },
- { kw: '葵花护肝片', note: '国产老牌药·品牌老化' },
- { kw: '解酒神器', note: '餐饮大池·酒局场景 VOC' },
- ],
- // ===== 包装 & 定价模块 =====
- package_pricing: [
- { kw: '护肝片 包装', note: '包装吐槽·"土"的印证' },
- { kw: '保健品 送礼装', note: '礼品包装偏好' },
- ],
- };
- // ============================================================
- // HTTP helper
- // ============================================================
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
- function httpGet(host, urlPath, auth, attempt = 1, maxAttempts = 3) {
- return new Promise((resolve, reject) => {
- const headers = { Accept: 'application/json' };
- if (auth) headers.Authorization = `Bearer ${auth}`;
- const req = https.request({
- hostname: host, path: urlPath, method: 'GET', headers, timeout: 45000,
- }, (res) => {
- const chunks = [];
- res.on('data', (c) => chunks.push(c));
- res.on('end', async () => {
- const body = Buffer.concat(chunks).toString('utf-8');
- if (res.statusCode === 200) {
- try { resolve(JSON.parse(body)); }
- catch (e) { resolve({ _error: { parse: e.message, body: body.slice(0, 300) } }); }
- } else if ([400, 429, 500, 502, 503, 504].includes(res.statusCode) && attempt < maxAttempts) {
- const wait = 1500 * Math.pow(1.8, attempt - 1);
- console.log(` ↻ retry#${attempt} in ${wait}ms (status=${res.statusCode})`);
- await sleep(wait);
- resolve(httpGet(host, urlPath, auth, attempt + 1, maxAttempts));
- } else {
- resolve({ _error: { status: res.statusCode, body: body.slice(0, 300) } });
- }
- });
- });
- req.on('error', async (err) => {
- if (attempt < maxAttempts) {
- await sleep(2000 * attempt);
- resolve(httpGet(host, urlPath, auth, attempt + 1, maxAttempts));
- } else reject(err);
- });
- req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
- req.end();
- });
- }
- // ============================================================
- // 小红书采集(单关键词)
- // ============================================================
- async function collectXhsForKeyword(kw, opts = {}) {
- const { topNotes = 5, commentsPerNote = 50 } = opts;
- console.log(`\n [xhs] 🔍 "${kw}"`);
- const searchRes = await httpGet('server.fmode.cn',
- `/thapi/v1/xiaohongshu/app/search_notes?keyword=${encodeURIComponent(kw)}&page=1`,
- TIKHUB_TOKEN);
- const items = searchRes?.data?.data?.items || [];
- const notes = items.map((w) => w.note).filter((n) => n?.id);
- console.log(` ✓ ${notes.length} 条笔记`);
- // 按评论数降序取前 N 篇
- const topNotesArr = notes
- .filter((n) => (n.comments_count || 0) > 3)
- .sort((a, b) => (b.comments_count || 0) - (a.comments_count || 0))
- .slice(0, topNotes);
- // 抓评论(可翻页到目标数量)
- const comments = {};
- for (const note of topNotesArr) {
- await sleep(600);
- const preview = String(note.title || note.desc || note.id).slice(0, 30);
- console.log(` 💬 "${preview}" (${note.comments_count} 评论)`);
- const noteComments = [];
- let cursor = '';
- let pages = 0;
- const maxPages = Math.ceil(commentsPerNote / 10);
- while (pages < maxPages) {
- try {
- const cRes = await httpGet('server.fmode.cn',
- `/thapi/v1/xiaohongshu/app/get_note_comments?note_id=${note.id}&cursor=${encodeURIComponent(cursor)}`,
- TIKHUB_TOKEN);
- const batch = cRes?.data?.data?.comments || [];
- if (!batch.length) break;
- noteComments.push(...batch);
- cursor = cRes?.data?.data?.cursor || '';
- if (!cursor || !cRes?.data?.data?.has_more) break;
- pages++;
- await sleep(400);
- } catch (e) {
- console.log(` ↻ ${note.id} err=${e.message}`);
- break;
- }
- }
- comments[note.id] = noteComments;
- console.log(` → ${noteComments.length} 条评论`);
- }
- // 收集作者 ID(不深挖用户详情,节省 API)
- const userIds = new Set();
- topNotesArr.forEach((n) => n?.user?.userid && userIds.add(n.user.userid));
- return { notes, top_notes: topNotesArr.map((n) => n.id), comments, user_ids: Array.from(userIds) };
- }
- // ============================================================
- // Main
- // ============================================================
- async function main() {
- const args = process.argv.slice(2);
- const force = args.includes('--force');
- const groupArg = args.find((a) => !a.startsWith('--'));
- // 读已有数据(续跑)
- let extended = {};
- if (fs.existsSync(OUT_PATH)) {
- try { extended = JSON.parse(fs.readFileSync(OUT_PATH, 'utf-8')); }
- catch { extended = {}; }
- }
- if (!extended.by_module) extended.by_module = {};
- if (!extended.meta) extended.meta = { created_at: new Date().toISOString() };
- const logLines = [];
- function log(msg) {
- console.log(msg);
- logLines.push(`[${new Date().toISOString()}] ${msg}`);
- }
- log('╔══════════════════════════════════════════════════════════╗');
- log('║ 江中肝纯片 · VOC 验证扩采(多平台评论颗粒度) ║');
- log('╚══════════════════════════════════════════════════════════╝');
- log(` 输出: ${OUT_PATH}`);
- log(` TikHub token: ${TIKHUB_TOKEN.slice(0, 8)}...`);
- const groups = groupArg ? [groupArg] : Object.keys(KEYWORD_GROUPS);
- log(` 目标模块: ${groups.join(', ')}`);
- log(` 强制重抓: ${force}`);
- log('');
- const t0 = Date.now();
- let totalNotes = 0, totalComments = 0;
- for (const groupName of groups) {
- if (!KEYWORD_GROUPS[groupName]) {
- log(` ⚠ 未知模块: ${groupName}`);
- continue;
- }
- log(`\n━━━━━ 模块: ${groupName} ━━━━━`);
- if (!extended.by_module[groupName]) extended.by_module[groupName] = {};
- for (const { kw, note } of KEYWORD_GROUPS[groupName]) {
- if (!force && extended.by_module[groupName][kw]?.notes?.length > 0) {
- log(` ⏭ 跳过(已采集): "${kw}" —— ${extended.by_module[groupName][kw].notes.length} 笔记 / ${Object.values(extended.by_module[groupName][kw].comments || {}).flat().length} 评论`);
- continue;
- }
- try {
- const result = await collectXhsForKeyword(kw, { topNotes: 5, commentsPerNote: 50 });
- result._meta = { keyword: kw, purpose: note, collected_at: new Date().toISOString() };
- extended.by_module[groupName][kw] = result;
- const nCnt = result.notes.length;
- const cCnt = Object.values(result.comments).flat().length;
- totalNotes += nCnt;
- totalComments += cCnt;
- log(` ✅ "${kw}" —— ${nCnt} 笔记 / ${cCnt} 评论`);
- // 每个关键词采完就保存,避免中断丢失
- fs.writeFileSync(OUT_PATH, JSON.stringify(extended, null, 2), 'utf-8');
- await sleep(1200);
- } catch (e) {
- log(` ❌ "${kw}" —— ${e.message}`);
- extended.by_module[groupName][kw] = { _error: e.message, _meta: { keyword: kw } };
- }
- }
- }
- extended.meta.last_updated = new Date().toISOString();
- extended.meta.total_notes = totalNotes;
- extended.meta.total_comments = totalComments;
- fs.writeFileSync(OUT_PATH, JSON.stringify(extended, null, 2), 'utf-8');
- fs.writeFileSync(LOG_PATH, logLines.join('\n'), 'utf-8');
- const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
- log('');
- log('╔══════════════════════════════════════════════════════════╗');
- log(`║ ✅ 扩采完成 (${elapsed}s) ${totalNotes} 笔记 / ${totalComments} 评论`);
- log('╚══════════════════════════════════════════════════════════╝');
- log(` 输出: ${OUT_PATH} (${(fs.statSync(OUT_PATH).size / 1024).toFixed(1)} KB)`);
- log(` 日志: ${LOG_PATH}`);
- // 保存最终 log
- fs.writeFileSync(LOG_PATH, logLines.join('\n'), 'utf-8');
- }
- main().catch((err) => {
- console.error('❌ 致命错误:', err);
- process.exit(1);
- });
|