04-phase4-collection-execution.md 13 KB

Phase 4 · 采集执行 Collection Execution

从采集矩阵 → 真实 API 调度 + 数据归一化 + 去重 + 假设打标 —— 这是整个工作流的"事实地基"。


🎯 目标

输入:P3 的采集矩阵(30-50 关键词 × 批次)
输出

  • docs/<品类>/raw/xhs/*.json(每关键词一个文件)
  • docs/<品类>/raw/douyin/*.json
  • docs/<品类>/raw/_merged.json(统一格式合并)
  • docs/<品类>/raw/comments-flat.jsonl(行级流,便于 grep)
  • docs/<品类>/raw/audit.log(采集审计日志)

🛠️ 采集脚本架构

核心文件:scripts/tools/<品类>-collect.js

结构骨架(300-500 行):

// ==========================================================
// 1. 常量 + 路径
// ==========================================================
const ROOT = path.resolve(__dirname, '..', '..');
const RAW = path.join(ROOT, 'docs', '<品类>', 'raw');
const XHS_DIR = path.join(RAW, 'xhs');
const DY_DIR = path.join(RAW, 'douyin');
const MERGED = path.join(RAW, '_merged.json');
const AUDIT = path.join(RAW, 'audit.log');

// ==========================================================
// 2. API 凭据加载
// ==========================================================
function loadTikHubKey() { ... }  // ~/.openclaw/skills/xiaohongshu-search-notes/api-config.json
function loadVocToken() { ... }   // ~/.openclaw/voc-credentials.json

// ==========================================================
// 3. 批次定义(BATCHES)
// ==========================================================
const BATCHES = {
    1: {
        xhs: [
            { keyword: '本品名', max_notes: 15, max_comments_per: 10, product: '本品' },
            // ...
        ],
        douyin: [
            { keyword: '本品名', max_items: 25, product: '本品' },
            // ...
        ],
    },
    2: { ... },
    3: { ... },
};

// ==========================================================
// 4. XHS 采集函数(TikHub)
// ==========================================================
async function collectXhs(task) {
    // 4.1 检查缓存
    if (existsCache(task.keyword) && !FORCE) return;
    
    // 4.2 调 TikHub search_notes
    const notes = await tikhubSearchNotes(task.keyword, task.max_notes);
    
    // 4.3 逐笔记调 note_comments
    const items = [];
    for (const note of notes) {
        const comments = await tikhubNoteComments(note.id, task.max_comments_per);
        for (const c of comments) {
            items.push(buildItem({
                platform: 'xhs',
                product: task.product,
                keyword: task.keyword,
                type: 'comment',
                nickname: c.user_nickname,
                ip: c.ip_location,
                content: c.content,
                likes: c.like_count,
                note_id: note.id,
                note_title: note.title,
                // ...
            }));
        }
    }
    
    // 4.4 落盘 + 审计
    fs.writeFileSync(path.join(XHS_DIR, `${safeName(task.keyword)}.json`),
        JSON.stringify(items, null, 2));
    logAudit(`OK ${task.keyword} → ${items.length}`);
}

// ==========================================================
// 5. 抖音采集函数(VOC Skill API)
// ==========================================================
async function collectDouyin(task) {
    // 调 hotSoonTopVideo / video_comments
    // ...
}

// ==========================================================
// 6. 统一 Item 构造
// ==========================================================
function buildItem({ platform, product, keyword, type, nickname, ip, content, likes, ...rest }) {
    const id = hash(platform + content + nickname);
    return {
        id, platform, product, keyword, type, nickname, ip, content,
        likes: Number(likes) || 0,
        tags: inferTags(content),
        hypotheses: inferHypotheses(content, keyword),
        sentiment: inferSentiment(content),
        collected_at: Date.now(),
        ...rest,
    };
}

// ==========================================================
// 7. 假设标注
// ==========================================================
function inferHypotheses(content, keyword) {
    const tags = [];
    if (/冷链|处方|医生开/.test(content)) tags.push('H1');
    if (/食品级|菌株|效果|没用|不靠谱/.test(content)) tags.push('H2');
    if (/便秘|积食|挑食|腹泻|宝宝|孩子/.test(content)) tags.push('H3');
    if (/入园|开学|抗生素|换季/.test(content)) tags.push('H4');
    if (/喂|吐出|苦|难吃|咀嚼|水果味/.test(content)) tags.push('H5');
    if (/包装|颜值|独立|一次一|礼盒/.test(content)) tags.push('H6');
    if (/吸收|营养糖|瓶瓶罐罐|DHA|补钙/.test(content)) tags.push('H7');
    if (/副作用|长期|依赖|糖|添加/.test(content)) tags.push('H8');
    return tags;
}

// ==========================================================
// 8. 合并函数
// ==========================================================
function mergeAll() {
    const items = [];
    scanDir(XHS_DIR, 'xhs', items);
    scanDir(DY_DIR, 'douyin', items);
    // 去重(同 id 合并)
    const dedup = new Map();
    for (const it of items) dedup.set(it.id, it);
    const merged = { 
        meta: { 
            count: dedup.size, 
            platforms: { xhs: ..., douyin: ... },
            hypotheses: tally H1-H8,
            collected_at: Date.now(),
        },
        items: [...dedup.values()],
    };
    fs.writeFileSync(MERGED, JSON.stringify(merged, null, 2));
    // 同时生成 JSONL 流
    fs.writeFileSync(path.join(RAW, 'comments-flat.jsonl'),
        [...dedup.values()].map(x => JSON.stringify(x)).join('\n'));
}

// ==========================================================
// 9. 主入口 + CLI
// ==========================================================
async function main() {
    const args = process.argv.slice(2);
    const batch = args.find(a => a.startsWith('--batch='))?.split('=')[1];
    if (args.includes('--merge')) return mergeAll();
    if (batch === 'all') { for (let i of [1,2,3]) await runBatch(i); }
    else if (batch) await runBatch(Number(batch));
    else if (args.includes('--test')) await testSingle();
    mergeAll();
}
main().catch(console.error);

详见 templates/collect.template.js


📋 API 速查

TikHub · 小红书

凭据~/.openclaw/skills/xiaohongshu-search-notes/api-config.json

{
  "currentToken": "gqsZHfMW..." 
}

接口

  • POST https://api.tikhub.io/api/v1/xiaohongshu/web_v1/search_notes
    • body: { keyword, sort_type: "general", note_type: "normal", total_number: N }
  • POST https://api.tikhub.io/api/v1/xiaohongshu/web_v1/note_comments
    • body: { note_id, cursor: "" }

关键响应字段

  • Note: id, user.nickname, title, desc, liked_count, comment_count, cover
  • Comment: content, like_count, user_info.nickname, ip_location, sub_comment_count

Rate Limit

  • 3-5 请求/秒
  • 建议每请求间隔 200-300ms
  • 失败 retry 2 次,指数退避(1s → 3s → 失败放弃)

VOC Skill · 抖音

凭据~/.openclaw/voc-credentials.json(Parse session token,余额 ≥ 1)

接口:通过 VOC Skill 封装(参考 ~/.openclaw/skills/social-*-comments/

关键调用(伪代码):

const videos = await vocApiSearch('抖音', keyword, { max: 20 });
for (const video of videos) {
    const comments = await vocApiComments('抖音', video.id, { max: 10 });
    // ...
}

Token 预飞:跑 node ~/.openclaw/tools/voc-token-preflight.js 确认 status=valid 才能跑。


Amazon(跨境项目才需要)

凭据:同 TikHub token(TikHub 也提供 Amazon 接口)

接口

  • POST https://api.tikhub.io/api/v1/amazon/...
  • 参数按 TikHub Amazon 文档

场景:肝纯片项目用了 Amazon 采集海外竞品(milk thistle / liver detox)


🔒 凭据与安全

绝对不能

  • ❌ 把 token 写死在代码里
  • ❌ commit .json 凭据文件到 git
  • ❌ 在日志里打印完整 token

标准做法

const tokenSnippet = (token || '').slice(0, 8) + '...';
console.log(`  TikHub token: ✓ ${tokenSnippet}`);

🚨 Token 失效与余额检查

运行前预飞

node ~/.openclaw/tools/voc-token-preflight.js

5 种 status: | status | 含义 | 处理 | |---|---|---| | valid | 正常 | 继续跑采集 | | missing | 凭据文件不存在 | node ~/.openclaw/tools/set-voc-token.js <token> | | expired | token 过期 | 去 apig-pay 登录拿新 token | | insufficient_balance | 余额 < 1 | 去 apig-pay 充值 | | error | 网络/API 异常 | 等 5 分钟重试 |


🛠️ 执行 Runbook

1. 首次启动检查

# 1.1 凭据
Test-Path ~/.openclaw/skills/xiaohongshu-search-notes/api-config.json
Test-Path ~/.openclaw/voc-credentials.json

# 1.2 Token 预飞
node ~/.openclaw/tools/voc-token-preflight.js

# 1.3 目录
mkdir -p docs/<品类>/raw/xhs docs/<品类>/raw/douyin

2. 测试单 kw(MVP 验证)

node scripts/tools/<品类>-collect.js --test
# 预期:采 1-2 个 kw 出 10-30 条真实评论

3. 正式批次

# Batch 1(P0 基础盘)
node scripts/tools/<品类>-collect.js --batch=1
# 预期:20-30 分钟,~500-1200 条

# Batch 2(P1 长尾 + 场景)
node scripts/tools/<品类>-collect.js --batch=2
# 预期:15-25 分钟,~400-800 条

# Batch 3(P2 决策 + 相邻)
node scripts/tools/<品类>-collect.js --batch=3
# 预期:12-20 分钟,~300-600 条

4. 合并

node scripts/tools/<品类>-collect.js --merge
# 生成 _merged.json + comments-flat.jsonl

5. 查 audit.log

Get-Content docs/<品类>/raw/audit.log
# 应该看到 OK 行,不能有 ERR 未处理

🔁 补采逻辑(Batch 4+)

如果 P5 发现 H6 / H7 覆盖不足:

// 在 BATCHES[4] 追加新 kw
BATCHES[4] = {
    xhs: [
        { keyword: '儿童无糖益生菌', max_notes: 10, max_comments_per: 8, product: '补采·H6' },
        { keyword: '宝宝专用', max_notes: 8, max_comments_per: 5, product: '补采·H6' },
    ],
};
node scripts/tools/<品类>-collect.js --batch=4 --force
node scripts/tools/<品类>-collect.js --merge
node scripts/tools/gen-<品类>.js

🧪 数据质量自检

采完后必须跑的 5 项检查:

node -e "
const d = require('./docs/<品类>/raw/_merged.json');
const items = d.items;
console.log('总量:', items.length);
console.log('平台分布:', JSON.stringify(d.meta.platforms));
console.log('H 假设分布:');
for (let i=1; i<=8; i++) {
    const n = items.filter(it => (it.hypotheses||[]).includes('H'+i)).length;
    console.log('  H'+i+':', n, n < 100 ? '⚠️ 不足' : '✓');
}
console.log('空内容:', items.filter(it => !it.content || it.content.length < 5).length);
console.log('重复 content 前 40 字:', 
    Array.from(new Map(items.map(it => [it.content.slice(0,40), it])).values()).length,
    '/', items.length);
"

🚫 常见失误

失误 1 · 一次跑完所有 batch 不 merge

  • ❌ 症状:采了 50 kw 才发现 API 返回格式不对,全部废掉
  • ✅ 做法:Batch 1 完先 --merge + 查看 _merged.json 验证格式

失误 2 · 忘记去重

  • ❌ 症状:同一条高赞评论在多个 kw 里都出现,在报告里刷 3 次
  • ✅ 做法:buildItem 时用 hash(platform + content + nickname) 做 id + merge 时 Map 去重

失误 3 · 对 --force 过于激进

  • ❌ 症状:每次调试都加 --force,白白消耗 API 额度
  • ✅ 做法:只在关键词配置改变时用 --force,日常调试用缓存

失误 4 · 没有 audit.log

  • ❌ 症状:某个 kw 抓失败不知道,最终数据不完整
  • ✅ 做法:每个 kw 必写一行 OK/SKIP/ERR/keyword/count/ms

失误 5 · 假设标注太宽泛

  • ❌ 症状:每条 VOC 都打 H1-H8 全部(标签失去区分度)
  • ✅ 做法:严格关键词匹配 + inferSentiment 辅助(差评 / 好评 / 中性)

✅ 退出门控

Phase 4 完成的 6 个条件

  1. ☐ 总量 ≥ 目标量(1500 / 2500 / 4000 按商业盘)
  2. 每条 H 假设 ≥ 100 条证据(不足就进 Batch 4 补采)
  3. 平台分布合理(XHS 40-70%,抖音 20-50%,电商按需)
  4. _merged.json 可加载,结构符合 {meta, items}
  5. audit.log 无 ERR 未处理
  6. ☐ 去重后无 完全相同 content 的 item 超过 5 条

📚 模板