collect.template.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428
  1. // ==============================================================================
  2. // <品类名> · 多平台 VOC 真实采集脚本(模板)
  3. // ==============================================================================
  4. // 用法:
  5. // node scripts/tools/<品类>-collect.js --test # 测试单 kw
  6. // node scripts/tools/<品类>-collect.js --batch=1 # P0 批次
  7. // node scripts/tools/<品类>-collect.js --batch=2 # P1 批次
  8. // node scripts/tools/<品类>-collect.js --batch=3 # P2 批次
  9. // node scripts/tools/<品类>-collect.js --batch=all # 全部
  10. // node scripts/tools/<品类>-collect.js --merge # 合并 → _merged.json
  11. // node scripts/tools/<品类>-collect.js --force # 强制重抓(忽略缓存)
  12. // ==============================================================================
  13. const fs = require('fs');
  14. const path = require('path');
  15. const https = require('https');
  16. const os = require('os');
  17. const crypto = require('crypto');
  18. // ==========================================================
  19. // 1. 常量 + 路径
  20. // ==========================================================
  21. const CATEGORY = '<品类>'; // TODO: 改为实际品类目录名
  22. const PROJECT_TAG = '<project-tag>'; // TODO: 简短英文标签,用于日志
  23. const ROOT = path.resolve(__dirname, '..', '..');
  24. const RAW = path.join(ROOT, 'docs', CATEGORY, 'raw');
  25. const XHS_DIR = path.join(RAW, 'xhs');
  26. const DY_DIR = path.join(RAW, 'douyin');
  27. const MERGED = path.join(RAW, '_merged.json');
  28. const AUDIT = path.join(RAW, 'audit.log');
  29. // 确保目录存在
  30. for (const dir of [RAW, XHS_DIR, DY_DIR]) {
  31. if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
  32. }
  33. // ==========================================================
  34. // 2. API 凭据加载
  35. // ==========================================================
  36. function loadTikHubKey() {
  37. const fp = path.join(os.homedir(), '.openclaw', 'skills', 'xiaohongshu-search-notes', 'api-config.json');
  38. if (!fs.existsSync(fp)) throw new Error(`TikHub 凭据缺失: ${fp}`);
  39. return JSON.parse(fs.readFileSync(fp, 'utf8')).currentToken;
  40. }
  41. function loadVocToken() {
  42. const fp = path.join(os.homedir(), '.openclaw', 'voc-credentials.json');
  43. if (!fs.existsSync(fp)) throw new Error(`VOC 凭据缺失: ${fp}`);
  44. return JSON.parse(fs.readFileSync(fp, 'utf8')).sessionToken;
  45. }
  46. // ==========================================================
  47. // 3. 批次定义(BATCHES)
  48. // TODO: 按 `3.采集矩阵.md` 里的关键词矩阵填充
  49. // ==========================================================
  50. const BATCHES = {
  51. 1: {
  52. // P0 · 本品 + 头部竞品
  53. xhs: [
  54. { keyword: '<本品名>', max_notes: 15, max_comments_per: 10, product: '<本品>' },
  55. { keyword: '<竞品A>', max_notes: 12, max_comments_per: 8, product: '<竞品A>' },
  56. // ... 10-15 个 kw
  57. ],
  58. douyin: [
  59. { keyword: '<本品名>', max_items: 25, product: '<本品>' },
  60. // ... 3-6 个 kw
  61. ],
  62. },
  63. 2: {
  64. // P1 · 场景 + 长尾
  65. xhs: [/* ... */],
  66. douyin: [/* ... */],
  67. },
  68. 3: {
  69. // P2 · 决策 + 相邻赛道
  70. xhs: [/* ... */],
  71. douyin: [/* ... */],
  72. },
  73. };
  74. // ==========================================================
  75. // 4. HTTP 工具
  76. // ==========================================================
  77. function httpRequest({ method = 'GET', host, path: urlPath, headers = {}, body = null }) {
  78. return new Promise((resolve, reject) => {
  79. const opts = { method, hostname: host, path: urlPath, headers };
  80. const req = https.request(opts, (res) => {
  81. let data = '';
  82. res.on('data', (c) => (data += c));
  83. res.on('end', () => {
  84. if (res.statusCode >= 200 && res.statusCode < 300) resolve({ status: res.statusCode, body: data });
  85. else reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`));
  86. });
  87. });
  88. req.on('error', reject);
  89. if (body) req.write(typeof body === 'string' ? body : JSON.stringify(body));
  90. req.end();
  91. });
  92. }
  93. async function retry(fn, times = 2, baseDelayMs = 1000) {
  94. for (let i = 0; i <= times; i++) {
  95. try { return await fn(); }
  96. catch (e) {
  97. if (i === times) throw e;
  98. await sleep(baseDelayMs * Math.pow(3, i));
  99. }
  100. }
  101. }
  102. const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  103. // ==========================================================
  104. // 5. TikHub · 小红书接口
  105. // ==========================================================
  106. async function tikhubSearchNotes(keyword, total = 10) {
  107. const token = loadTikHubKey();
  108. const res = await httpRequest({
  109. method: 'POST',
  110. host: 'api.tikhub.io',
  111. path: '/api/v1/xiaohongshu/web_v1/search_notes',
  112. headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
  113. body: { keyword, sort_type: 'general', note_type: 'normal', total_number: total },
  114. });
  115. const parsed = JSON.parse(res.body);
  116. return (parsed.data?.items || parsed.data?.notes || []).slice(0, total);
  117. }
  118. async function tikhubNoteComments(noteId, max = 10) {
  119. const token = loadTikHubKey();
  120. const res = await httpRequest({
  121. method: 'POST',
  122. host: 'api.tikhub.io',
  123. path: '/api/v1/xiaohongshu/web_v1/note_comments',
  124. headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
  125. body: { note_id: noteId, cursor: '' },
  126. });
  127. const parsed = JSON.parse(res.body);
  128. return (parsed.data?.comments || []).slice(0, max);
  129. }
  130. // ==========================================================
  131. // 6. 小红书采集器
  132. // ==========================================================
  133. async function collectXhs(task) {
  134. const fp = path.join(XHS_DIR, `${safeName(task.keyword)}.json`);
  135. if (fs.existsSync(fp) && !FORCE) {
  136. logAudit(`SKIP xhs/${task.keyword} → cached`);
  137. return;
  138. }
  139. const start = Date.now();
  140. try {
  141. const notes = await retry(() => tikhubSearchNotes(task.keyword, task.max_notes));
  142. await sleep(250);
  143. const items = [];
  144. for (const note of notes) {
  145. try {
  146. const comments = await retry(() => tikhubNoteComments(note.id || note.note_id, task.max_comments_per));
  147. for (const c of comments) {
  148. items.push(buildItem({
  149. platform: 'xhs',
  150. product: task.product,
  151. keyword: task.keyword,
  152. type: 'comment',
  153. nickname: c.user_info?.nickname || c.user_nickname || '匿名',
  154. ip: c.ip_location,
  155. content: c.content,
  156. likes: c.like_count,
  157. note_id: note.id || note.note_id,
  158. note_title: note.display_title || note.title,
  159. note_author: note.user?.nickname,
  160. }));
  161. }
  162. await sleep(200);
  163. } catch (e) {
  164. logAudit(`ERR xhs/${task.keyword}/note ${note.id}: ${e.message.slice(0, 80)}`);
  165. }
  166. }
  167. fs.writeFileSync(fp, JSON.stringify(items, null, 2), 'utf8');
  168. logAudit(`OK xhs/${task.keyword} → ${items.length} (${Date.now() - start}ms)`);
  169. } catch (e) {
  170. logAudit(`ERR xhs/${task.keyword}: ${e.message.slice(0, 80)}`);
  171. }
  172. }
  173. // ==========================================================
  174. // 7. 抖音采集器(通过 VOC Skill)
  175. // TODO: 根据实际 VOC Skill API 调整
  176. // ==========================================================
  177. async function collectDouyin(task) {
  178. const fp = path.join(DY_DIR, `${safeName(task.keyword)}.json`);
  179. if (fs.existsSync(fp) && !FORCE) {
  180. logAudit(`SKIP douyin/${task.keyword} → cached`);
  181. return;
  182. }
  183. const start = Date.now();
  184. try {
  185. // 具体 API 因项目而定,此处伪代码
  186. const videos = await retry(() => vocApiHotVideos(task.keyword, task.max_items));
  187. await sleep(250);
  188. const items = [];
  189. for (const video of videos) {
  190. try {
  191. const comments = await retry(() => vocApiVideoComments(video.id, 10));
  192. for (const c of comments) {
  193. items.push(buildItem({
  194. platform: 'douyin',
  195. product: task.product,
  196. keyword: task.keyword,
  197. type: 'comment',
  198. nickname: c.nickname || '匿名',
  199. ip: c.ip_label,
  200. content: c.text,
  201. likes: c.digg_count,
  202. video_id: video.id,
  203. video_title: video.desc,
  204. video_author: video.author?.nickname,
  205. }));
  206. }
  207. await sleep(200);
  208. } catch (e) {
  209. logAudit(`ERR douyin/${task.keyword}/video ${video.id}: ${e.message.slice(0, 80)}`);
  210. }
  211. }
  212. fs.writeFileSync(fp, JSON.stringify(items, null, 2), 'utf8');
  213. logAudit(`OK douyin/${task.keyword} → ${items.length} (${Date.now() - start}ms)`);
  214. } catch (e) {
  215. logAudit(`ERR douyin/${task.keyword}: ${e.message.slice(0, 80)}`);
  216. }
  217. }
  218. // VOC Skill API stubs - 根据实际 skill 替换
  219. async function vocApiHotVideos(keyword, max) { /* TODO */ return []; }
  220. async function vocApiVideoComments(videoId, max) { /* TODO */ return []; }
  221. // ==========================================================
  222. // 8. 统一 Item 构造
  223. // ==========================================================
  224. function buildItem({ platform, product, keyword, type, nickname, ip, content, likes, ...rest }) {
  225. const idSource = `${platform}:${nickname}:${(content || '').slice(0, 60)}`;
  226. const id = crypto.createHash('md5').update(idSource).digest('hex').slice(0, 16);
  227. return {
  228. id,
  229. platform,
  230. product,
  231. keyword,
  232. type,
  233. nickname: nickname || '匿名',
  234. ip: ip || '',
  235. content: String(content || '').trim(),
  236. likes: Number(likes) || 0,
  237. tags: inferTags(content),
  238. hypotheses: inferHypotheses(content, keyword),
  239. sentiment: inferSentiment(content),
  240. collected_at: Date.now(),
  241. ...rest,
  242. };
  243. }
  244. // ==========================================================
  245. // 9. 标签推断(按品类定制)
  246. // TODO: 修改 regex 以匹配品类特征词
  247. // ==========================================================
  248. function inferTags(content) {
  249. const c = String(content || '');
  250. const tags = [];
  251. if (/有效|管用|好了|改善|通了/.test(c)) tags.push('有效');
  252. if (/没用|假的|没效果|坑|避雷/.test(c)) tags.push('无效');
  253. if (/推荐|安利|必买/.test(c)) tags.push('推荐');
  254. if (/贵|便宜|性价比|价格/.test(c)) tags.push('价格');
  255. if (/孩子|宝宝|儿童|娃/.test(c)) tags.push('儿童');
  256. // TODO: 按品类加其他标签
  257. return tags;
  258. }
  259. function inferHypotheses(content, keyword) {
  260. const c = String(content || '');
  261. const tags = [];
  262. // TODO: 按 H1-H8 各自的判断逻辑
  263. if (/<H1 regex>/.test(c)) tags.push('H1');
  264. if (/<H2 regex>/.test(c)) tags.push('H2');
  265. if (/<H3 regex>/.test(c)) tags.push('H3');
  266. if (/<H4 regex>/.test(c)) tags.push('H4');
  267. if (/<H5 regex>/.test(c)) tags.push('H5');
  268. if (/<H6 regex>/.test(c)) tags.push('H6');
  269. if (/<H7 regex>/.test(c)) tags.push('H7');
  270. if (/<H8 regex>/.test(c)) tags.push('H8');
  271. return tags;
  272. }
  273. function inferSentiment(content) {
  274. const c = String(content || '');
  275. if (/没用|假的|避雷|坑|差评|不行|退货/.test(c)) return 'negative';
  276. if (/有效|好用|推荐|爱|治好|管用|回购/.test(c)) return 'positive';
  277. return 'neutral';
  278. }
  279. // ==========================================================
  280. // 10. 合并函数
  281. // ==========================================================
  282. function mergeAll() {
  283. console.log('\n▶ Merging all collected raw data...');
  284. const allItems = [];
  285. scanDir(XHS_DIR, 'xhs', allItems);
  286. scanDir(DY_DIR, 'douyin', allItems);
  287. // 去重(同 id 合并,保留 likes 较高的版本)
  288. const dedup = new Map();
  289. for (const it of allItems) {
  290. const ex = dedup.get(it.id);
  291. if (!ex || (it.likes || 0) > (ex.likes || 0)) dedup.set(it.id, it);
  292. }
  293. const items = [...dedup.values()];
  294. // 统计
  295. const platforms = {};
  296. const products = new Set();
  297. const hypTally = { H1: 0, H2: 0, H3: 0, H4: 0, H5: 0, H6: 0, H7: 0, H8: 0 };
  298. for (const it of items) {
  299. platforms[it.platform] = (platforms[it.platform] || 0) + 1;
  300. products.add(it.product);
  301. for (const h of it.hypotheses || []) if (hypTally[h] !== undefined) hypTally[h]++;
  302. }
  303. const merged = {
  304. meta: {
  305. count: items.length,
  306. platforms,
  307. products: [...products],
  308. hypotheses: hypTally,
  309. collected_at: Date.now(),
  310. category: CATEGORY,
  311. },
  312. items,
  313. };
  314. fs.writeFileSync(MERGED, JSON.stringify(merged, null, 2), 'utf8');
  315. fs.writeFileSync(
  316. path.join(RAW, 'comments-flat.jsonl'),
  317. items.map((x) => JSON.stringify(x)).join('\n'),
  318. 'utf8'
  319. );
  320. console.log(` ✅ merged: ${items.length} items | ${products.size} products | ${Object.keys(platforms).length} platforms`);
  321. console.log(` 假设覆盖: ${Object.entries(hypTally).map(([k, v]) => `${k}:${v}`).join(' / ')}`);
  322. }
  323. function scanDir(dir, platform, arr) {
  324. if (!fs.existsSync(dir)) return;
  325. for (const f of fs.readdirSync(dir)) {
  326. if (!f.endsWith('.json')) continue;
  327. try {
  328. const data = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
  329. if (Array.isArray(data)) arr.push(...data);
  330. } catch (e) {
  331. console.warn(` ⚠️ 无法解析 ${platform}/${f}: ${e.message}`);
  332. }
  333. }
  334. }
  335. // ==========================================================
  336. // 11. 工具函数
  337. // ==========================================================
  338. function safeName(s) {
  339. return String(s).replace(/[\\/:*?"<>|\s]/g, '_').slice(0, 100);
  340. }
  341. function logAudit(line) {
  342. const ts = new Date().toISOString();
  343. const full = `[${ts}] ${line}\n`;
  344. fs.appendFileSync(AUDIT, full, 'utf8');
  345. console.log(' ' + line);
  346. }
  347. // ==========================================================
  348. // 12. 主入口 + CLI
  349. // ==========================================================
  350. let FORCE = false;
  351. async function runBatch(n) {
  352. const b = BATCHES[n];
  353. if (!b) { console.error(`❌ Batch ${n} 未定义`); return; }
  354. console.log(`\n▶ Running Batch ${n}...`);
  355. for (const task of (b.xhs || [])) {
  356. await collectXhs(task);
  357. await sleep(300);
  358. }
  359. for (const task of (b.douyin || [])) {
  360. await collectDouyin(task);
  361. await sleep(300);
  362. }
  363. }
  364. async function testSingle() {
  365. const first = BATCHES[1].xhs[0];
  366. if (first) { console.log('Test mode → 采 1 个 XHS kw'); await collectXhs(first); }
  367. mergeAll();
  368. }
  369. async function main() {
  370. const args = process.argv.slice(2);
  371. FORCE = args.includes('--force');
  372. console.log('╔══════════════════════════════════════════════╗');
  373. console.log(`║ ${CATEGORY} · VOC 多平台真实采集 [${PROJECT_TAG}]`.padEnd(48) + '║');
  374. console.log('╚══════════════════════════════════════════════╝');
  375. try { console.log(' TikHub token: ✓', loadTikHubKey().slice(0, 8) + '...'); }
  376. catch { console.log(' TikHub token: ✗(XHS 采集不可用)'); }
  377. try { console.log(' VOC token: ✓', loadVocToken().slice(0, 8) + '...'); }
  378. catch { console.log(' VOC token: ✗(抖音采集不可用)'); }
  379. if (args.includes('--test')) return testSingle();
  380. if (args.includes('--merge')) return mergeAll();
  381. const batchArg = args.find((a) => a.startsWith('--batch='))?.split('=')[1];
  382. if (batchArg === 'all') {
  383. for (const n of Object.keys(BATCHES)) await runBatch(Number(n));
  384. } else if (batchArg) {
  385. await runBatch(Number(batchArg));
  386. } else {
  387. console.log('\n用法: --test | --batch=1|2|3|all | --merge | --force');
  388. return;
  389. }
  390. mergeAll();
  391. }
  392. main().catch((e) => { console.error('❌ FATAL:', e); process.exit(1); });